@barefootjs/jsx 0.15.2 → 0.17.0
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/env-signal.d.ts +38 -15
- package/dist/adapters/env-signal.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +7 -6
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +29 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/builtin-lowering-plugins.d.ts +34 -0
- package/dist/builtin-lowering-plugins.d.ts.map +1 -0
- package/dist/expression-parser.d.ts +219 -163
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6892 -6118
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +122 -0
- package/dist/lowering-registry.d.ts.map +1 -0
- package/dist/profiler.d.ts +115 -0
- package/dist/profiler.d.ts.map +1 -1
- package/dist/query-href-lowering.d.ts +63 -0
- package/dist/query-href-lowering.d.ts.map +1 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +169 -11
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
- package/src/__tests__/analyzer.test.ts +53 -0
- package/src/__tests__/expression-parser.test.ts +703 -391
- package/src/__tests__/ir-reduce-op.test.ts +18 -21
- package/src/__tests__/ir-sort-comparator.test.ts +19 -20
- package/src/__tests__/lowering-registry.test.ts +141 -0
- package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
- package/src/__tests__/profiler.test.ts +149 -0
- package/src/__tests__/query-href-recognition.test.ts +58 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +204 -0
- package/src/__tests__/unsupported-expression.test.ts +98 -4
- package/src/adapters/env-signal.ts +60 -21
- package/src/adapters/jsx-adapter.ts +17 -0
- package/src/adapters/parsed-expr-emitter.ts +39 -41
- package/src/analyzer-context.ts +72 -27
- package/src/analyzer.ts +226 -9
- package/src/builtin-lowering-plugins.ts +54 -0
- package/src/expression-parser.ts +1183 -927
- package/src/index.ts +35 -3
- package/src/ir-to-client-js/csr-substitute.ts +5 -0
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
- package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
- package/src/jsx-to-ir.ts +182 -43
- package/src/lowering-registry.ts +160 -0
- package/src/profiler.ts +328 -0
- package/src/query-href-lowering.ts +147 -0
- package/src/ssr-defaults.ts +5 -1
- package/src/types.ts +171 -12
- package/src/__tests__/flatmap-support.test.ts +0 -218
- package/src/__tests__/reduce-op.test.ts +0 -201
package/src/expression-parser.ts
CHANGED
|
@@ -14,7 +14,16 @@ import ts from 'typescript'
|
|
|
14
14
|
|
|
15
15
|
export type ParsedExpr =
|
|
16
16
|
| { kind: 'identifier'; name: string }
|
|
17
|
-
|
|
17
|
+
// `raw` is the numeric literal's `ts.NumericLiteral.text` — the token TS
|
|
18
|
+
// itself normalises (separators stripped, radix / exponent folded to
|
|
19
|
+
// decimal: `1_000`/`0x10`/`1e3` → `1000`/`16`/`1000`). It is NOT the
|
|
20
|
+
// verbatim source spelling. Its value is that it equals the exact string an
|
|
21
|
+
// adapter's literal lowering already emits, so a structured lowering matches
|
|
22
|
+
// byte-for-byte — which the lossy `parseFloat` `value` can't guarantee (e.g.
|
|
23
|
+
// `parseFloat('1_000')` is 1, and large integers lose precision). Only
|
|
24
|
+
// populated for numeric literals; string / boolean / null carry their
|
|
25
|
+
// canonical form in `value`.
|
|
26
|
+
| { kind: 'literal'; value: string | number | boolean | null; literalType: 'string' | 'number' | 'boolean' | 'null'; raw?: string }
|
|
18
27
|
| { kind: 'call'; callee: ParsedExpr; args: ParsedExpr[] }
|
|
19
28
|
| { kind: 'member'; object: ParsedExpr; property: string; computed: boolean }
|
|
20
29
|
// Element access with a NON-literal index (`selected()[index]`,
|
|
@@ -30,9 +39,34 @@ export type ParsedExpr =
|
|
|
30
39
|
| { kind: 'conditional'; test: ParsedExpr; consequent: ParsedExpr; alternate: ParsedExpr }
|
|
31
40
|
| { kind: 'logical'; op: '&&' | '||' | '??'; left: ParsedExpr; right: ParsedExpr }
|
|
32
41
|
| { kind: 'template-literal'; parts: TemplatePart[] }
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
// Expression-bodied arrow (`(a, b) => …`), multi-parameter. A
|
|
43
|
+
// single-`return` block body is normalised to its returned expression;
|
|
44
|
+
// a single object-binding-pattern param (`({done}) => done`) is rewritten
|
|
45
|
+
// to a synthetic identifier param with dotted-access body. Higher-order
|
|
46
|
+
// callbacks (`.filter`/`.sort`/`.reduce`/`.flatMap`) arrive as a generic
|
|
47
|
+
// `call` whose argument is this kind; the adapter serializes `body` to the
|
|
48
|
+
// runtime evaluator (#2018). Block bodies with locals / multiple statements,
|
|
49
|
+
// and array-binding-pattern params, stay `unsupported`.
|
|
50
|
+
| { kind: 'arrow'; params: string[]; body: ParsedExpr }
|
|
51
|
+
// A regex literal carried as its exact source text (`/\/+$/`), so the Go
|
|
52
|
+
// ctor lowering matches the one trailing-slash-strip pattern it recognises
|
|
53
|
+
// (`String.replace`). Outside that narrow surface a regex resolves to
|
|
54
|
+
// `unsupported`.
|
|
55
|
+
| { kind: 'regex'; raw: string }
|
|
35
56
|
| { kind: 'array-literal'; elements: ParsedExpr[] }
|
|
57
|
+
// Object literal `{ a: 1, b: x }` / shorthand `{ a }`. Carried so an
|
|
58
|
+
// adapter that lowers an object *value* (Go `map[string]interface{}`,
|
|
59
|
+
// Perl hashref) can emit from structure instead of re-parsing the
|
|
60
|
+
// source with `ts.createSourceFile`. Only produced for plain literals:
|
|
61
|
+
// every property is a non-computed `key: value` or shorthand `{ key }`.
|
|
62
|
+
// Spreads, computed keys, methods, and getters/setters fall through to
|
|
63
|
+
// `unsupported` (unchanged). `raw` is the original expression string —
|
|
64
|
+
// the same value the old `unsupported` fallback carried — so an adapter
|
|
65
|
+
// that does not yet consume `properties` stays byte-identical by
|
|
66
|
+
// emitting it exactly as it emits `unsupported`. Extending the type
|
|
67
|
+
// adds a TS compile error in every exhaustive `ParsedExpr` switch, the
|
|
68
|
+
// same drift defence used for `array-literal` / `array-method`.
|
|
69
|
+
| { kind: 'object-literal'; properties: ObjectLiteralProperty[]; raw: string }
|
|
36
70
|
// Non-higher-order array methods. Discriminated by `method` so each
|
|
37
71
|
// adapter handles the full set via one exhaustive switch instead of
|
|
38
72
|
// sprinkling per-method branches across the call / member emitters.
|
|
@@ -65,39 +99,6 @@ export type ParsedExpr =
|
|
|
65
99
|
object: ParsedExpr
|
|
66
100
|
args: ParsedExpr[]
|
|
67
101
|
}
|
|
68
|
-
// `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B). The comparator is
|
|
69
|
-
// extracted into a structured `SortComparator` at parse time — the
|
|
70
|
-
// arrow function never reaches `args`, so adapters don't have to
|
|
71
|
-
// re-walk the arrow-fn ParsedExpr to recover the key / direction
|
|
72
|
-
// (and the same shape feeds both standalone position and the
|
|
73
|
-
// `.sort().map()` chained-loop hoist in `jsx-to-ir.ts`). If the
|
|
74
|
-
// comparator doesn't match the supported catalogue
|
|
75
|
-
// (`extractSortComparatorFromTS` below), parsing falls
|
|
76
|
-
// through to `unsupported` so adapters surface BF101 with an
|
|
77
|
-
// @client suggestion.
|
|
78
|
-
| {
|
|
79
|
-
kind: 'array-method'
|
|
80
|
-
method: 'sort' | 'toSorted'
|
|
81
|
-
object: ParsedExpr
|
|
82
|
-
args: []
|
|
83
|
-
comparator: SortComparator
|
|
84
|
-
}
|
|
85
|
-
// `.reduce(fn, init)` (#1448 Tier C). Like sort, the reducer is
|
|
86
|
-
// extracted into a structured `ReduceOp` at parse time — the
|
|
87
|
-
// two-param arrow never reaches `args`, so adapters fold via a
|
|
88
|
-
// runtime helper instead of re-walking the callback. The accepted
|
|
89
|
-
// catalogue is the arithmetic-fold family only (`acc + key` /
|
|
90
|
-
// `acc * key`, numeric or string-concat); any other reducer body,
|
|
91
|
-
// or a missing initial value, falls through to `unsupported` so
|
|
92
|
-
// adapters surface BF101 with an @client suggestion. See
|
|
93
|
-
// `extractReduceOpFromTS` below.
|
|
94
|
-
| {
|
|
95
|
-
kind: 'array-method'
|
|
96
|
-
method: 'reduce' | 'reduceRight'
|
|
97
|
-
object: ParsedExpr
|
|
98
|
-
args: []
|
|
99
|
-
reduceOp: ReduceOp
|
|
100
|
-
}
|
|
101
102
|
// `.flat(depth?)` (#1448 Tier C). The flatten depth is validated and
|
|
102
103
|
// normalised into a structured `FlatDepth` at parse time — the literal
|
|
103
104
|
// never reaches `args`, so adapters fold via a runtime helper instead of
|
|
@@ -111,22 +112,29 @@ export type ParsedExpr =
|
|
|
111
112
|
args: []
|
|
112
113
|
flatDepth: FlatDepth
|
|
113
114
|
}
|
|
114
|
-
// `.flatMap(fn)` value-returning field projection (#1448 Tier C). The
|
|
115
|
-
// callback is extracted into a structured `FlatMapOp` (self / field
|
|
116
|
-
// projection) at parse time, mirroring sort / reduce. The projected
|
|
117
|
-
// per-item value is flattened one level (flatMap = map + flat(1)).
|
|
118
|
-
// Array-literal / complex callbacks fall through to `unsupported`; the
|
|
119
|
-
// JSX-returning `.flatMap` is handled as an `IRLoop` upstream and never
|
|
120
|
-
// reaches here. See `extractFlatMapOpFromTS` below.
|
|
121
|
-
| {
|
|
122
|
-
kind: 'array-method'
|
|
123
|
-
method: 'flatMap'
|
|
124
|
-
object: ParsedExpr
|
|
125
|
-
args: []
|
|
126
|
-
flatMapOp: FlatMapOp
|
|
127
|
-
}
|
|
128
115
|
| { kind: 'unsupported'; raw: string; reason: string }
|
|
129
116
|
|
|
117
|
+
/**
|
|
118
|
+
* One property of an `object-literal` `ParsedExpr`. The key is the
|
|
119
|
+
* resolved (non-computed) property name — for `{ a: 1 }` and shorthand
|
|
120
|
+
* `{ a }` it is `a`; for `{ 'a-b': 1 }` it is `a-b`. Computed keys
|
|
121
|
+
* (`{ [k]: 1 }`) are not represented; such literals fall through to
|
|
122
|
+
* `unsupported` at parse time.
|
|
123
|
+
*/
|
|
124
|
+
export type ObjectLiteralProperty = {
|
|
125
|
+
key: string
|
|
126
|
+
// The syntactic kind of the key, since `key` normalises all three to a
|
|
127
|
+
// string and so loses the distinction. A consumer that must treat a numeric
|
|
128
|
+
// key (`{ 1: 'a' }`) differently from a same-text string key (`{ '1': 'a' }`)
|
|
129
|
+
// reads this; most consumers ignore it. `identifier` for shorthand.
|
|
130
|
+
keyKind?: 'identifier' | 'string' | 'numeric'
|
|
131
|
+
// Shorthand `{ a }` (the value is the identifier `a`) vs explicit
|
|
132
|
+
// `{ a: <value> }`. The `value` already carries the resolved tree
|
|
133
|
+
// either way; this flag is kept for re-stringification fidelity.
|
|
134
|
+
shorthand: boolean
|
|
135
|
+
value: ParsedExpr
|
|
136
|
+
}
|
|
137
|
+
|
|
130
138
|
/**
|
|
131
139
|
* One comparison key inside a sort comparator. A simple
|
|
132
140
|
* `(a, b) => a.f - b.f` produces a single key; a multi-key
|
|
@@ -152,83 +160,19 @@ export type SortKey = {
|
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
/**
|
|
155
|
-
* Structured form of a JS `(a, b) => …` sort comparator.
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* `
|
|
159
|
-
* `
|
|
163
|
+
* Structured form of a JS `(a, b) => …` sort comparator. Recovered from
|
|
164
|
+
* the generic `arrow` callback body by {@link sortComparatorFromArrow} as
|
|
165
|
+
* the LEGACY fallback for a comparator the runtime evaluator can't model
|
|
166
|
+
* (`localeCompare` string sorts — `serializeParsedExpr` refuses them).
|
|
167
|
+
* Consumed by the adapters' `bf_sort` / `bf->sort` emit. The shape is
|
|
168
|
+
* intentionally finite — see {@link sortComparatorFromArrow} for the
|
|
169
|
+
* accepted catalogue.
|
|
160
170
|
*/
|
|
161
171
|
export type SortComparator = {
|
|
162
172
|
// Comparison keys in priority order. A simple comparator has one
|
|
163
173
|
// key; a `||`-chained multi-key comparator has one per operand.
|
|
164
174
|
// Always length >= 1.
|
|
165
175
|
keys: SortKey[]
|
|
166
|
-
// Original JS source of the comparator body; preserved so `@client`
|
|
167
|
-
// fallback can re-emit the user's exact expression if the call site
|
|
168
|
-
// ever gets relocated to the runtime. For block-body comparators
|
|
169
|
-
// this is the returned expression, not the `{ … }` block — so the
|
|
170
|
-
// client fallback's synthetic `(a, b) => raw` arrow stays valid.
|
|
171
|
-
raw: string
|
|
172
|
-
// The two parameter names the user wrote (e.g. `a`/`b`, or
|
|
173
|
-
// `lhs`/`rhs`). Only consumed by the client-side `@client`
|
|
174
|
-
// fallback path that ships the raw comparator body to JS — it
|
|
175
|
-
// needs to bind these names in a closure so `raw` evaluates
|
|
176
|
-
// against the right operands. Server-side lowering doesn't read
|
|
177
|
-
// them.
|
|
178
|
-
paramA: string
|
|
179
|
-
paramB: string
|
|
180
|
-
// Which JS method name the user wrote — both shapes share the same
|
|
181
|
-
// lowering (templates render a snapshot, so the JS mutate vs new
|
|
182
|
-
// distinction is moot) but we preserve the original for source maps
|
|
183
|
-
// and error messages.
|
|
184
|
-
method: 'sort' | 'toSorted'
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Structured form of a JS `.reduce((acc, item) => …, init)` call,
|
|
189
|
-
* built once at parse time and consumed by both template adapters'
|
|
190
|
-
* `reduceMethod()` emit (#1448 Tier C). The shape is intentionally
|
|
191
|
-
* finite — only the arithmetic-fold family is lowerable in a
|
|
192
|
-
* declarative template; arbitrary accumulator bodies are not. See
|
|
193
|
-
* `extractReduceOpFromTS` for the accepted catalogue.
|
|
194
|
-
*/
|
|
195
|
-
export type ReduceOp = {
|
|
196
|
-
// The fold operator between accumulator and per-item value. `+`
|
|
197
|
-
// covers numeric sums and (with a string init) string concatenation;
|
|
198
|
-
// `*` covers numeric products. Subtraction / division are excluded —
|
|
199
|
-
// they're order-sensitive and rarely written as a `.reduce`.
|
|
200
|
-
op: '+' | '*'
|
|
201
|
-
// What value each item contributes to the fold:
|
|
202
|
-
// { kind: 'self' } → `acc + item` (primitive array)
|
|
203
|
-
// { kind: 'field', field } → `acc + item.field` (struct-field accessor)
|
|
204
|
-
key: { kind: 'self' } | { kind: 'field'; field: string }
|
|
205
|
-
// Numeric fold vs string concatenation. Determined by the init
|
|
206
|
-
// literal's type: a number init folds numerically; a string init
|
|
207
|
-
// (only valid with `+`) concatenates. Both template runtimes apply
|
|
208
|
-
// the same coercion so their output stays byte-equal; this can
|
|
209
|
-
// diverge from JS for floating-point sums whose decimal expansion
|
|
210
|
-
// differs by runtime (rare in SSR data — integer sums agree).
|
|
211
|
-
type: 'numeric' | 'string'
|
|
212
|
-
// Decoded initial-accumulator value (never raw source). For a numeric
|
|
213
|
-
// fold this is TypeScript's canonical decimal form (`1_000` -> `1000`,
|
|
214
|
-
// `0x10` -> `16`) so `strconv.ParseFloat` / Perl agree; for a concat
|
|
215
|
-
// fold it's the contents of a quoted string literal (only single- or
|
|
216
|
-
// double-quoted `ts.StringLiteral` seeds are accepted — template
|
|
217
|
-
// literals and escape-carrying literals are refused at parse time, so
|
|
218
|
-
// the value is an escape-free single-line string, e.g. the empty
|
|
219
|
-
// string "" or a separator like ", "). Round-trip emitters re-quote a
|
|
220
|
-
// string init via `JSON.stringify`; a numeric init re-emits as-is.
|
|
221
|
-
init: string
|
|
222
|
-
// Original JS source of the reducer body (the returned expression
|
|
223
|
-
// for block bodies). Lets the `@client` fallback ship the user's
|
|
224
|
-
// exact arrow to the JS runtime.
|
|
225
|
-
raw: string
|
|
226
|
-
// The two parameter names the user wrote (e.g. `acc`/`item`, or
|
|
227
|
-
// `sum`/`t`). Only the `@client` fallback reads them — it binds them
|
|
228
|
-
// in a synthetic `(acc, item) => raw` arrow. Server-side lowering
|
|
229
|
-
// works off `op` / `key` / `init` and ignores them.
|
|
230
|
-
paramAcc: string
|
|
231
|
-
paramItem: string
|
|
232
176
|
}
|
|
233
177
|
|
|
234
178
|
/**
|
|
@@ -241,38 +185,6 @@ export type ReduceOp = {
|
|
|
241
185
|
*/
|
|
242
186
|
export type FlatDepth = number | 'infinity'
|
|
243
187
|
|
|
244
|
-
/**
|
|
245
|
-
* A single non-computed projection leaf on the flatMap callback param —
|
|
246
|
-
* the item itself (`i`) or one of its fields (`i.field`). Shared by the
|
|
247
|
-
* scalar and tuple `FlatMapOp` projections.
|
|
248
|
-
*/
|
|
249
|
-
export type FlatMapLeaf = { kind: 'self' } | { kind: 'field'; field: string }
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Structured form of a value-returning `.flatMap(fn)` callback (#1448
|
|
253
|
-
* Tier C). The accepted catalogue:
|
|
254
|
-
*
|
|
255
|
-
* i => i → self projection (flatten one level)
|
|
256
|
-
* i => i.field → field projection (flatten a per-item array field)
|
|
257
|
-
* i => [i.a, i.b] → tuple projection (gather per-item leaves)
|
|
258
|
-
*
|
|
259
|
-
* The scalar `self` / `field` projections return a value that is then
|
|
260
|
-
* flattened one level (flatMap = map + flat(1)) — a non-array value is
|
|
261
|
-
* kept as-is, matching JS. The `tuple` projection returns an array
|
|
262
|
-
* literal; flat(1) removes only that literal's wrapper, so each leaf is
|
|
263
|
-
* appended verbatim (an array-valued leaf is NOT spread). Leaves outside
|
|
264
|
-
* self / field (literals, `i.a + 1`, calls, deep access) refuse with
|
|
265
|
-
* BF101. See `extractFlatMapOpFromTS`.
|
|
266
|
-
*/
|
|
267
|
-
export type FlatMapOp = {
|
|
268
|
-
// What each item projects to before the one-level flatten.
|
|
269
|
-
projection: FlatMapLeaf | { kind: 'tuple'; elements: FlatMapLeaf[] }
|
|
270
|
-
// The callback param name the user wrote (for the `@client` round-trip).
|
|
271
|
-
param: string
|
|
272
|
-
// Original JS source of the callback body (for the `@client` fallback).
|
|
273
|
-
raw: string
|
|
274
|
-
}
|
|
275
|
-
|
|
276
188
|
export type TemplatePart =
|
|
277
189
|
| { type: 'string'; value: string }
|
|
278
190
|
| { type: 'expression'; expr: ParsedExpr }
|
|
@@ -669,6 +581,72 @@ export function parseExpression(expr: string): ParsedExpr {
|
|
|
669
581
|
return convertNode(firstStmt.expression, expr)
|
|
670
582
|
}
|
|
671
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Convert an already-parsed TypeScript node directly into a {@link ParsedExpr},
|
|
586
|
+
* for a consumer that already holds a `ts.Node` (e.g. a `.sort()` callback at
|
|
587
|
+
* the loop-hoist site) and wants the structured conversion without re-parsing
|
|
588
|
+
* source via `ts.createSourceFile`. An `unsupported` result still carries the
|
|
589
|
+
* node's text in `raw` for debugging (a synthetic node with no source file
|
|
590
|
+
* falls back to '').
|
|
591
|
+
*/
|
|
592
|
+
export function tsNodeToParsedExpr(node: ts.Node): ParsedExpr {
|
|
593
|
+
let raw = ''
|
|
594
|
+
try {
|
|
595
|
+
raw = node.getText()
|
|
596
|
+
} catch {
|
|
597
|
+
/* synthetic node without a source file */
|
|
598
|
+
}
|
|
599
|
+
return convertNode(node, raw)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Higher-order array methods whose callback body the runtime evaluator drives
|
|
604
|
+
* (#2018). Recognised generically as a `call` whose callee is `<recv>.<method>`
|
|
605
|
+
* and whose first argument is an `arrow`; the adapter serializes the arrow body
|
|
606
|
+
* to the evaluator. `map` is excluded — a JSX-returning `.map` is an IRLoop
|
|
607
|
+
* upstream, and a value-returning `.map` has no template lowering.
|
|
608
|
+
*/
|
|
609
|
+
export const CALLBACK_METHODS: ReadonlySet<string> = new Set([
|
|
610
|
+
'filter', 'every', 'some', 'find', 'findIndex', 'findLast', 'findLastIndex',
|
|
611
|
+
'sort', 'toSorted', 'reduce', 'reduceRight', 'flatMap',
|
|
612
|
+
])
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* A recognised higher-order callback method call: `<object>.<method>(<arrow>,
|
|
616
|
+
* …rest)` where `method ∈` {@link CALLBACK_METHODS} and the first argument is a
|
|
617
|
+
* generic `arrow`. Returns the receiver, the callback arrow, and any trailing
|
|
618
|
+
* args (e.g. the `.reduce` init), or `null` if the shape doesn't match. The
|
|
619
|
+
* single recognition point shared by the support gate and the adapter dispatch.
|
|
620
|
+
*/
|
|
621
|
+
export function asCallbackMethodCall(expr: ParsedExpr): {
|
|
622
|
+
method: string
|
|
623
|
+
object: ParsedExpr
|
|
624
|
+
arrow: Extract<ParsedExpr, { kind: 'arrow' }>
|
|
625
|
+
args: ParsedExpr[]
|
|
626
|
+
} | null {
|
|
627
|
+
if (expr.kind !== 'call') return null
|
|
628
|
+
if (expr.callee.kind !== 'member' || expr.callee.computed) return null
|
|
629
|
+
if (!CALLBACK_METHODS.has(expr.callee.property)) return null
|
|
630
|
+
const arrow = expr.args[0]
|
|
631
|
+
if (!arrow || arrow.kind !== 'arrow') return null
|
|
632
|
+
return { method: expr.callee.property, object: expr.callee.object, arrow, args: expr.args.slice(1) }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Resolve a non-computed object-literal property key to its string name.
|
|
637
|
+
* Identifier / string / numeric names resolve to their text; a computed
|
|
638
|
+
* (`[expr]`) or otherwise non-plain key returns null so the caller treats
|
|
639
|
+
* the whole literal as `unsupported`.
|
|
640
|
+
*/
|
|
641
|
+
function objectLiteralKeyName(
|
|
642
|
+
name: ts.PropertyName,
|
|
643
|
+
): { key: string; keyKind: 'identifier' | 'string' | 'numeric' } | null {
|
|
644
|
+
if (ts.isIdentifier(name)) return { key: name.text, keyKind: 'identifier' }
|
|
645
|
+
if (ts.isStringLiteral(name)) return { key: name.text, keyKind: 'string' }
|
|
646
|
+
if (ts.isNumericLiteral(name)) return { key: name.text, keyKind: 'numeric' }
|
|
647
|
+
return null
|
|
648
|
+
}
|
|
649
|
+
|
|
672
650
|
/**
|
|
673
651
|
* Convert a TypeScript AST node to ParsedExpr.
|
|
674
652
|
*/
|
|
@@ -683,10 +661,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
683
661
|
return { kind: 'literal', value: node.text, literalType: 'string' }
|
|
684
662
|
}
|
|
685
663
|
|
|
686
|
-
// Numeric literal: 0, 5, 3.14
|
|
664
|
+
// Numeric literal: 0, 5, 3.14. Keep `ts.NumericLiteral.text` in `raw` (TS's
|
|
665
|
+
// normalised token — `1_000`/`0x10`/`1e3` → `1000`/`16`/`1000`) so an adapter
|
|
666
|
+
// emits the exact string its own literal lowering already produces; `value`
|
|
667
|
+
// is the parsed number for structural reasoning.
|
|
687
668
|
if (ts.isNumericLiteral(node)) {
|
|
688
669
|
const value = parseFloat(node.text)
|
|
689
|
-
return { kind: 'literal', value, literalType: 'number' }
|
|
670
|
+
return { kind: 'literal', value, literalType: 'number', raw: node.text }
|
|
690
671
|
}
|
|
691
672
|
|
|
692
673
|
// Boolean literals and null
|
|
@@ -705,37 +686,26 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
705
686
|
const callee = convertNode(node.expression, raw)
|
|
706
687
|
const args = node.arguments.map(arg => convertNode(arg, raw))
|
|
707
688
|
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
args.length === 1 &&
|
|
729
|
-
args[0].kind === 'identifier' &&
|
|
730
|
-
args[0].name === 'Boolean'
|
|
731
|
-
) {
|
|
732
|
-
return {
|
|
733
|
-
kind: 'higher-order',
|
|
734
|
-
method: 'filter',
|
|
735
|
-
object: callee.object,
|
|
736
|
-
param: '_',
|
|
737
|
-
predicate: { kind: 'identifier', name: '_' },
|
|
738
|
-
}
|
|
689
|
+
// Higher-order callback methods (`.filter`/`.find`/`.every`/`.some`/
|
|
690
|
+
// `.sort`/`.reduce`/`.flatMap`/…) are NOT folded here (#2018 P5): they
|
|
691
|
+
// flow through as a generic `call` whose argument is a generic `arrow`,
|
|
692
|
+
// and the adapter recognises the callback shape at dispatch and serializes
|
|
693
|
+
// the arrow body to the runtime evaluator. The one exception is the
|
|
694
|
+
// non-arrow `.filter(Boolean)` callable: synthesise the equivalent
|
|
695
|
+
// identity arrow `_ => _` so it flows through the same callback lowering
|
|
696
|
+
// (the adapter keeps a dedicated truthiness fallback for the identity body).
|
|
697
|
+
if (
|
|
698
|
+
callee.kind === 'member' &&
|
|
699
|
+
!callee.computed &&
|
|
700
|
+
callee.property === 'filter' &&
|
|
701
|
+
args.length === 1 &&
|
|
702
|
+
args[0].kind === 'identifier' &&
|
|
703
|
+
args[0].name === 'Boolean'
|
|
704
|
+
) {
|
|
705
|
+
return {
|
|
706
|
+
kind: 'call',
|
|
707
|
+
callee,
|
|
708
|
+
args: [{ kind: 'arrow', params: ['_'], body: { kind: 'identifier', name: '_' } }],
|
|
739
709
|
}
|
|
740
710
|
}
|
|
741
711
|
|
|
@@ -931,9 +901,12 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
931
901
|
// replacing the FIRST occurrence (JS semantics for a string
|
|
932
902
|
// pattern). Go uses `bf_replace` (`strings.Replace` with n=1);
|
|
933
903
|
// Mojo uses `bf->replace` (index/substr splice, no regex). A
|
|
934
|
-
// regex-literal pattern
|
|
935
|
-
//
|
|
936
|
-
//
|
|
904
|
+
// regex-literal pattern is the deferred form — its `regex` first
|
|
905
|
+
// arg is carried STRUCTURALLY (not collapsed to `unsupported`) so
|
|
906
|
+
// the Go ctor lowering can recover the one trailing-slash pattern
|
|
907
|
+
// it supports without re-parsing (#2039). Template use stays
|
|
908
|
+
// refused: `isSupported` maps a regex-pattern `.replace` to the
|
|
909
|
+
// deferred-form BF101 reason — the Perl `s///` vs Go
|
|
937
910
|
// `regexp.ReplaceAllString` flavour gap is the open design
|
|
938
911
|
// question in #1448. `replaceAll` stays refused entirely.
|
|
939
912
|
//
|
|
@@ -951,28 +924,30 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
951
924
|
}
|
|
952
925
|
}
|
|
953
926
|
// A regex-literal pattern is the deferred form (the Perl `s///`
|
|
954
|
-
// vs Go `regexp.ReplaceAllString` flavour gap, #1448)
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
927
|
+
// vs Go `regexp.ReplaceAllString` flavour gap, #1448). Its shape
|
|
928
|
+
// is carried structurally — `args[0]` is a `regex` node (convertNode
|
|
929
|
+
// line ~1127) — so a consumer that recognises one fixed pattern (the
|
|
930
|
+
// Go ctor's `/\/+$/` trailing-slash strip → `strings.TrimRight`) reads
|
|
931
|
+
// it from the tree instead of re-parsing the raw (#2039). Returned
|
|
932
|
+
// before the object-literal `badArg` check below so the regex form
|
|
933
|
+
// keeps its dedicated diagnostic precedence; `isSupported` then refuses
|
|
934
|
+
// any template use with the deferred-form reason.
|
|
959
935
|
const patternNode = node.arguments[0]
|
|
960
936
|
if (patternNode && ts.isRegularExpressionLiteral(patternNode)) {
|
|
961
|
-
return {
|
|
962
|
-
kind: 'unsupported',
|
|
963
|
-
raw,
|
|
964
|
-
reason:
|
|
965
|
-
'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 */',
|
|
966
|
-
}
|
|
937
|
+
return { kind: 'array-method', method: 'replace', object: callee.object, args }
|
|
967
938
|
}
|
|
939
|
+
// Treat an object-literal argument like `unsupported` — a `.replace`
|
|
940
|
+
// with an object pattern/replacement isn't lowerable, same as before
|
|
941
|
+
// the `object-literal` kind existed (byte-identical; Roadmap A-1).
|
|
968
942
|
const badArg =
|
|
969
|
-
args[0].kind === 'unsupported'
|
|
943
|
+
args[0].kind === 'unsupported' || args[0].kind === 'object-literal'
|
|
970
944
|
? args[0]
|
|
971
|
-
: args[1].kind === 'unsupported'
|
|
945
|
+
: args[1].kind === 'unsupported' || args[1].kind === 'object-literal'
|
|
972
946
|
? args[1]
|
|
973
947
|
: undefined
|
|
974
|
-
if (badArg
|
|
975
|
-
|
|
948
|
+
if (badArg) {
|
|
949
|
+
const reason = badArg.kind === 'unsupported' ? badArg.reason : 'Unsupported syntax: ObjectLiteralExpression'
|
|
950
|
+
return { kind: 'unsupported', raw, reason }
|
|
976
951
|
}
|
|
977
952
|
return { kind: 'array-method', method: 'replace', object: callee.object, args }
|
|
978
953
|
}
|
|
@@ -1004,123 +979,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1004
979
|
if (callee.property === 'padStart' || callee.property === 'padEnd') {
|
|
1005
980
|
return { kind: 'array-method', method: callee.property, object: callee.object, args }
|
|
1006
981
|
}
|
|
1007
|
-
// `.sort
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
// locale/options args stay out of scope — see #1448 Tier B
|
|
1015
|
-
// follow-up.
|
|
1016
|
-
if ((callee.property === 'sort' || callee.property === 'toSorted') && node.arguments.length === 1) {
|
|
1017
|
-
// Extract from the raw TS AST (not args[0] ParsedExpr) — the
|
|
1018
|
-
// standard arrow-fn convertNode path refuses two-param arrows,
|
|
1019
|
-
// so the comparator would otherwise reach us as `unsupported`.
|
|
1020
|
-
const comparator = extractSortComparatorFromTS(node.arguments[0], callee.property)
|
|
1021
|
-
if (comparator) {
|
|
1022
|
-
return {
|
|
1023
|
-
kind: 'array-method',
|
|
1024
|
-
method: callee.property,
|
|
1025
|
-
object: callee.object,
|
|
1026
|
-
args: [],
|
|
1027
|
-
comparator,
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
return {
|
|
1031
|
-
kind: 'unsupported',
|
|
1032
|
-
raw,
|
|
1033
|
-
reason:
|
|
1034
|
-
`Sort comparator shape not supported. Accepted:\n` +
|
|
1035
|
-
` (a, b) => a - b\n` +
|
|
1036
|
-
` (a, b) => a.field - b.field\n` +
|
|
1037
|
-
` (a, b) => a.localeCompare(b)\n` +
|
|
1038
|
-
` (a, b) => a.field.localeCompare(b.field)\n` +
|
|
1039
|
-
` (a, b) => a.field > b.field ? 1 : -1 (relational ternary)\n` +
|
|
1040
|
-
` any of the above ||-chained for multi-key tie-breaks\n` +
|
|
1041
|
-
`(reverse the operands for descending order). ` +
|
|
1042
|
-
`Wrap the call in /* @client */ to evaluate at hydration.`,
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// `.reduce(fn, init)` / `.reduceRight(fn, init)` (#1448 Tier C).
|
|
1047
|
-
// The reducer + init are extracted into a structured `ReduceOp` at
|
|
1048
|
-
// parse time; the two-param arrow never reaches the standard
|
|
1049
|
-
// convertNode path (which refuses it), so we read the raw TS AST.
|
|
1050
|
-
// Only the arithmetic-fold catalogue lowers — anything else, or a
|
|
1051
|
-
// missing init, falls through to `unsupported` (BF101 + @client
|
|
1052
|
-
// hint). `reduceRight` shares the catalogue; the method name is
|
|
1053
|
-
// preserved so adapters fold right-to-left (only observable for
|
|
1054
|
-
// string concatenation — numeric sum / product are commutative).
|
|
1055
|
-
if (
|
|
1056
|
-
(callee.property === 'reduce' || callee.property === 'reduceRight') &&
|
|
1057
|
-
node.arguments.length === 2
|
|
1058
|
-
) {
|
|
1059
|
-
const reduceOp = extractReduceOpFromTS(node.arguments[0], node.arguments[1])
|
|
1060
|
-
if (reduceOp) {
|
|
1061
|
-
return {
|
|
1062
|
-
kind: 'array-method',
|
|
1063
|
-
method: callee.property,
|
|
1064
|
-
object: callee.object,
|
|
1065
|
-
args: [],
|
|
1066
|
-
reduceOp,
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
const m = callee.property
|
|
1070
|
-
return {
|
|
1071
|
-
kind: 'unsupported',
|
|
1072
|
-
raw,
|
|
1073
|
-
reason:
|
|
1074
|
-
`Reduce shape not supported. Accepted (arithmetic fold, explicit init):\n` +
|
|
1075
|
-
` arr.${m}((acc, x) => acc + x, 0)\n` +
|
|
1076
|
-
` arr.${m}((acc, x) => acc + x.field, 0)\n` +
|
|
1077
|
-
` arr.${m}((acc, x) => acc * x.field, 1)\n` +
|
|
1078
|
-
` arr.${m}((acc, x) => acc + x.field, '') (string concat)\n` +
|
|
1079
|
-
`The accumulator must be the left operand and the initial ` +
|
|
1080
|
-
`value a number / string literal. ` +
|
|
1081
|
-
`Wrap the call in /* @client */ to evaluate at hydration.`,
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
// A `.reduce(fn)` without an initial value can't be lowered: JS
|
|
1085
|
-
// throws on an empty array, which a template can't mirror. Fall
|
|
1086
|
-
// through to the BF101 gate with the @client escape hatch.
|
|
1087
|
-
|
|
1088
|
-
// `.flatMap(fn)` value-returning projection (#1448 Tier C). The
|
|
1089
|
-
// callback is extracted into a structured `FlatMapOp` (self / field
|
|
1090
|
-
// scalar projection, or an array-literal tuple of self / field
|
|
1091
|
-
// leaves) from the raw TS AST. The JSX-returning form is handled as
|
|
1092
|
-
// an `IRLoop` upstream and never reaches here; richer callbacks
|
|
1093
|
-
// refuse with BF101 + the @client hint. Go uses `bf_flat_map` /
|
|
1094
|
-
// `bf_flat_map_tuple`; Mojo uses `bf->flat_map` / `bf->flat_map_tuple`.
|
|
1095
|
-
// Intercept EVERY `.flatMap(...)` call (not just the 1-arg form) so
|
|
1096
|
-
// the off-catalogue and wrong-arity shapes get this tailored reason
|
|
1097
|
-
// rather than the generic "flatMap has no template lowering" gate
|
|
1098
|
-
// message, which now misleads (the field-projection form does lower).
|
|
1099
|
-
if (callee.property === 'flatMap') {
|
|
1100
|
-
const flatMapOp =
|
|
1101
|
-
node.arguments.length === 1 ? extractFlatMapOpFromTS(node.arguments[0]) : null
|
|
1102
|
-
if (flatMapOp) {
|
|
1103
|
-
return {
|
|
1104
|
-
kind: 'array-method',
|
|
1105
|
-
method: 'flatMap',
|
|
1106
|
-
object: callee.object,
|
|
1107
|
-
args: [],
|
|
1108
|
-
flatMapOp,
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
return {
|
|
1112
|
-
kind: 'unsupported',
|
|
1113
|
-
raw,
|
|
1114
|
-
reason:
|
|
1115
|
-
`flatMap shape not supported. Accepted (self / field leaves, no thisArg):\n` +
|
|
1116
|
-
` arr.flatMap(i => i) (flatten one level)\n` +
|
|
1117
|
-
` arr.flatMap(i => i.field) (flatten a per-item array field)\n` +
|
|
1118
|
-
` arr.flatMap(i => [i.a, i.b]) (gather per-item fields)\n` +
|
|
1119
|
-
`Richer callbacks (computed / nested access, arithmetic, calls, ` +
|
|
1120
|
-
`literal elements) and the 2-arg \`flatMap(fn, thisArg)\` form ` +
|
|
1121
|
-
`aren't lowered. Wrap the call in /* @client */ to evaluate at hydration.`,
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
982
|
+
// `.sort` / `.toSorted` / `.reduce` / `.reduceRight` / `.flatMap`
|
|
983
|
+
// (callback methods) are NOT folded here (#2018 P5): they fall through
|
|
984
|
+
// to the generic `call` construction below (callee = member, arg =
|
|
985
|
+
// generic `arrow`), and the adapter serializes the arrow body to the
|
|
986
|
+
// runtime evaluator. The localeCompare-sort fallback recovers a
|
|
987
|
+
// structured comparator from the generic arrow via
|
|
988
|
+
// `sortComparatorFromArrow`.
|
|
1124
989
|
}
|
|
1125
990
|
|
|
1126
991
|
return { kind: 'call', callee, args }
|
|
@@ -1132,6 +997,29 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1132
997
|
return { kind: 'array-literal', elements }
|
|
1133
998
|
}
|
|
1134
999
|
|
|
1000
|
+
// Object literal: { a: 1, b: x, shorthand }. Only plain literals are
|
|
1001
|
+
// structured — every property must be a non-computed `key: value` or a
|
|
1002
|
+
// shorthand `{ key }`. Anything else (spread, computed key, method,
|
|
1003
|
+
// getter/setter) falls through to the generic `unsupported` fallback
|
|
1004
|
+
// below, exactly as before this kind existed.
|
|
1005
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
1006
|
+
const properties: ObjectLiteralProperty[] = []
|
|
1007
|
+
for (const prop of node.properties) {
|
|
1008
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
1009
|
+
const k = objectLiteralKeyName(prop.name)
|
|
1010
|
+
if (k === null) return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
|
|
1011
|
+
properties.push({ key: k.key, keyKind: k.keyKind, shorthand: false, value: convertNode(prop.initializer, raw) })
|
|
1012
|
+
} else if (ts.isShorthandPropertyAssignment(prop)) {
|
|
1013
|
+
const key = prop.name.text
|
|
1014
|
+
properties.push({ key, keyKind: 'identifier', shorthand: true, value: { kind: 'identifier', name: key } })
|
|
1015
|
+
} else {
|
|
1016
|
+
// Spread assignment, method, getter/setter — not a plain map.
|
|
1017
|
+
return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return { kind: 'object-literal', properties, raw }
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1135
1023
|
// Property access: user.name, items().length
|
|
1136
1024
|
if (ts.isPropertyAccessExpression(node)) {
|
|
1137
1025
|
const object = convertNode(node.expression, raw)
|
|
@@ -1164,7 +1052,10 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1164
1052
|
// (the literal forms above fold into a static property path; this
|
|
1165
1053
|
// one can't). #1897 (data-table).
|
|
1166
1054
|
const index = convertNode(argNode, raw)
|
|
1167
|
-
|
|
1055
|
+
// An object-literal index (`arr[{…}]`) isn't lowerable — surface it
|
|
1056
|
+
// as the whole expression, exactly as an `unsupported` index did
|
|
1057
|
+
// before the kind existed (byte-identical; Roadmap A-1).
|
|
1058
|
+
if (index.kind === 'unsupported' || index.kind === 'object-literal') return index
|
|
1168
1059
|
return { kind: 'index-access', object, index }
|
|
1169
1060
|
}
|
|
1170
1061
|
|
|
@@ -1232,24 +1123,63 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1232
1123
|
return { kind: 'literal', value: node.text, literalType: 'string' }
|
|
1233
1124
|
}
|
|
1234
1125
|
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1126
|
+
// Regex literal: `/\/+$/`. Carried as exact source text for the Go ctor
|
|
1127
|
+
// trailing-slash-strip lowering (`String.replace`).
|
|
1128
|
+
if (ts.isRegularExpressionLiteral(node)) {
|
|
1129
|
+
return { kind: 'regex', raw: node.getText() }
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Arrow function / function expression: `x => expr`, `(a, b) => expr`,
|
|
1133
|
+
// `({field}) => field`, `function (x) { return … }`. Produces a generic
|
|
1134
|
+
// multi-parameter `arrow` (#2018 P5). A single-`return` block body
|
|
1135
|
+
// normalises to its returned expression; a single object-binding-pattern
|
|
1136
|
+
// param is rewritten to a synthetic identifier param with dotted-access
|
|
1137
|
+
// body (destructure support). Higher-order callbacks reach the adapter as
|
|
1138
|
+
// a generic `call` whose argument is this kind; the adapter serializes
|
|
1139
|
+
// `body` to the runtime evaluator.
|
|
1140
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
1141
|
+
// Resolve the body expression: an expression-bodied arrow carries it
|
|
1142
|
+
// directly; a block body (arrow `=> { … }` or a function expression)
|
|
1143
|
+
// must reduce to exactly one `return <expr>;`. Multi-statement /
|
|
1144
|
+
// local-var bodies stay refused.
|
|
1145
|
+
let body: ParsedExpr
|
|
1146
|
+
if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
|
|
1147
|
+
body = convertNode(node.body, raw)
|
|
1148
|
+
} else {
|
|
1149
|
+
const block = node.body as ts.Block
|
|
1150
|
+
const stmts = block.statements
|
|
1151
|
+
// Fast path: a single `return <expr>` keeps the pre-#2040 conversion
|
|
1152
|
+
// (convertNode on the returned node), byte-identical for the existing
|
|
1153
|
+
// corpus.
|
|
1154
|
+
if (stmts.length === 1 && ts.isReturnStatement(stmts[0]) && stmts[0].expression) {
|
|
1155
|
+
body = convertNode(stmts[0].expression, raw)
|
|
1156
|
+
} else {
|
|
1157
|
+
// General value-producing block: normalize `let`-inline + value `if` /
|
|
1158
|
+
// early `return` into a single expression (#2040). Imperative shapes
|
|
1159
|
+
// (raw `for` / `while`, `break`, mutation, side effects) don't parse
|
|
1160
|
+
// into ParsedStatement, or fall through without a value — either way we
|
|
1161
|
+
// refuse with an actionable reason and adapters surface BF101.
|
|
1162
|
+
let sf: ts.SourceFile | undefined
|
|
1163
|
+
try {
|
|
1164
|
+
sf = block.getSourceFile()
|
|
1165
|
+
} catch {
|
|
1166
|
+
sf = undefined
|
|
1167
|
+
}
|
|
1168
|
+
const parsed = sf
|
|
1169
|
+
? parseBlockBody(block, sf, n => n.getText(sf))
|
|
1170
|
+
: null
|
|
1171
|
+
if (!parsed) {
|
|
1172
|
+
return { kind: 'unsupported', raw, reason: IMPERATIVE_BLOCK_REASON }
|
|
1173
|
+
}
|
|
1174
|
+
const folded = foldBlockToExpr(parsed)
|
|
1175
|
+
if (!folded.ok) {
|
|
1176
|
+
return { kind: 'unsupported', raw, reason: folded.reason }
|
|
1177
|
+
}
|
|
1178
|
+
body = folded.expr
|
|
1179
|
+
}
|
|
1249
1180
|
}
|
|
1250
|
-
const body = convertNode(node.body, raw)
|
|
1251
1181
|
|
|
1252
|
-
//
|
|
1182
|
+
// Single object-binding-pattern param: `({done}) => done` (#1443),
|
|
1253
1183
|
// `({user: {name}}) => name` (#1530), `({done = false}) => done`
|
|
1254
1184
|
// (#1531), `({done, ...rest}) => rest.priority` (#1532). We
|
|
1255
1185
|
// synthesise the equivalent dotted-access form so adapters can
|
|
@@ -1262,7 +1192,8 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1262
1192
|
// shapes refuse with BF021 (#1532). Array binding patterns,
|
|
1263
1193
|
// nested rest, and defaults at non-leaf (nested-pattern) slots
|
|
1264
1194
|
// stay unsupported.
|
|
1265
|
-
if (ts.isObjectBindingPattern(
|
|
1195
|
+
if (node.parameters.length === 1 && ts.isObjectBindingPattern(node.parameters[0].name)) {
|
|
1196
|
+
const bindingPattern = node.parameters[0].name
|
|
1266
1197
|
const fieldMap = new Map<string, DestructureBinding>()
|
|
1267
1198
|
// `excludedTopKeys` mirrors the JS rest-binding exclusion set:
|
|
1268
1199
|
// the source-object keys explicitly consumed at the top level
|
|
@@ -1271,7 +1202,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1271
1202
|
// keys on local rename / leaf names and would miss
|
|
1272
1203
|
// `({done: d, ...rest}) => rest.done` or `({user: {name}, ...rest}) => rest.user`.
|
|
1273
1204
|
const excludedTopKeys = new Set<string>()
|
|
1274
|
-
const collect = collectDestructureBindings(
|
|
1205
|
+
const collect = collectDestructureBindings(bindingPattern, [], fieldMap, raw, excludedTopKeys)
|
|
1275
1206
|
if (!collect.ok) {
|
|
1276
1207
|
return { kind: 'unsupported', raw, reason: collect.reason }
|
|
1277
1208
|
}
|
|
@@ -1335,37 +1266,20 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1335
1266
|
}
|
|
1336
1267
|
const syntheticParam = pickSyntheticParam(fieldMap, body)
|
|
1337
1268
|
const rewritten = substituteDestructuredFields(body, fieldMap, syntheticParam, restName)
|
|
1338
|
-
return { kind: 'arrow
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
if (!ts.isIdentifier(param.name)) {
|
|
1342
|
-
return { kind: 'unsupported', raw, reason: 'Destructuring parameters are not supported' }
|
|
1269
|
+
return { kind: 'arrow', params: [syntheticParam], body: rewritten }
|
|
1343
1270
|
}
|
|
1344
|
-
return { kind: 'arrow-fn', param: param.name.text, body }
|
|
1345
|
-
}
|
|
1346
1271
|
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
const param = node.parameters[0]
|
|
1357
|
-
if (!ts.isIdentifier(param.name)) {
|
|
1358
|
-
return { kind: 'unsupported', raw, reason: 'Destructured params in function expressions are not supported' }
|
|
1359
|
-
}
|
|
1360
|
-
const stmts = node.body.statements
|
|
1361
|
-
if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) {
|
|
1362
|
-
return { kind: 'unsupported', raw, reason: 'Function expressions must be `function (x) { return <expr> }`' }
|
|
1363
|
-
}
|
|
1364
|
-
return {
|
|
1365
|
-
kind: 'arrow-fn',
|
|
1366
|
-
param: param.name.text,
|
|
1367
|
-
body: convertNode(stmts[0].expression, raw),
|
|
1272
|
+
// Every (remaining) parameter must be a plain identifier. Multi-param
|
|
1273
|
+
// arrows (sort comparators `(a, b) => …`) and single-param identifier
|
|
1274
|
+
// arrows (`x => …`) both land here.
|
|
1275
|
+
const params: string[] = []
|
|
1276
|
+
for (const p of node.parameters) {
|
|
1277
|
+
if (!ts.isIdentifier(p.name)) {
|
|
1278
|
+
return { kind: 'unsupported', raw, reason: 'Only identifier (or a single object-destructure) function parameters are supported' }
|
|
1279
|
+
}
|
|
1280
|
+
params.push(p.name.text)
|
|
1368
1281
|
}
|
|
1282
|
+
return { kind: 'arrow', params, body }
|
|
1369
1283
|
}
|
|
1370
1284
|
|
|
1371
1285
|
// Default: unsupported
|
|
@@ -1373,114 +1287,56 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1373
1287
|
}
|
|
1374
1288
|
|
|
1375
1289
|
/**
|
|
1376
|
-
* Recover a
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
* need both param names to decide direction (`a-b` asc vs `b-a` desc).
|
|
1382
|
-
*
|
|
1383
|
-
* The accepted catalogue is finite so the walker stays shallow — no
|
|
1384
|
-
* constant folding, no symbol resolution, no inference of "this looks
|
|
1385
|
-
* like it might sort numerically". Returns null if the shape doesn't
|
|
1386
|
-
* match exactly, in which case the caller emits an `unsupported` IR
|
|
1387
|
-
* node and adapters surface BF101.
|
|
1290
|
+
* Recover a {@link SortComparator} from a generic `(a, b) => …` sort callback
|
|
1291
|
+
* arrow (#2018 P5). The LEGACY fallback for a comparator the runtime evaluator
|
|
1292
|
+
* can't model (`localeCompare` string sorts — `serializeParsedExpr` refuses
|
|
1293
|
+
* them); the adapter calls this only when the eval path returns null. Operates
|
|
1294
|
+
* on the generic `arrow` ParsedExpr (params + body subtree) — no `ts` re-parse.
|
|
1388
1295
|
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
* and function expression. Multi-statement / local-var bodies stay
|
|
1393
|
-
* refused (deferred follow-up).
|
|
1296
|
+
* The accepted catalogue is finite so the walker stays shallow — no constant
|
|
1297
|
+
* folding, no symbol resolution. Returns null if the shape doesn't match
|
|
1298
|
+
* exactly, in which case the adapter surfaces BF101.
|
|
1394
1299
|
*
|
|
1395
|
-
* A body is split on top-level `||` into one leaf per operand, giving
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
* order):
|
|
1300
|
+
* A body is split on top-level `||` into one leaf per operand, giving a
|
|
1301
|
+
* multi-key comparator (`a.x - b.x || a.y - b.y` → sort by x, then y). Accepted
|
|
1302
|
+
* leaf shapes (each paired ascending / descending by operand order):
|
|
1399
1303
|
*
|
|
1400
1304
|
* a.field - b.field → field, numeric
|
|
1401
1305
|
* a - b → self, numeric
|
|
1402
1306
|
* a.field.localeCompare(b.field) → field, string
|
|
1403
1307
|
* a.localeCompare(b) → self, string
|
|
1404
1308
|
* a.field > b.field ? 1 : -1 → field, auto (relational ternary)
|
|
1405
|
-
* a.field < b.field ? -1 : 1 → field, auto
|
|
1406
1309
|
* a < b ? -1 : a > b ? 1 : 0 → self/field, auto (3-way)
|
|
1407
1310
|
* a === b ? 0 : <relational ternary> → leading-tie 3-way
|
|
1408
1311
|
*
|
|
1409
|
-
* Function-reference comparators and `localeCompare(b, locale, opts)`
|
|
1410
|
-
*
|
|
1312
|
+
* Function-reference comparators and `localeCompare(b, locale, opts)` (the
|
|
1313
|
+
* multi-arg form) return null — deferred follow-ups.
|
|
1411
1314
|
*/
|
|
1412
|
-
export function
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
const pA = node.parameters[0]
|
|
1420
|
-
const pB = node.parameters[1]
|
|
1421
|
-
if (!ts.isIdentifier(pA.name) || !ts.isIdentifier(pB.name)) return null
|
|
1422
|
-
const paramA = pA.name.text
|
|
1423
|
-
const paramB = pB.name.text
|
|
1424
|
-
|
|
1425
|
-
// Resolve the comparator body. Expression-bodied arrows carry it
|
|
1426
|
-
// directly; block bodies (both arrow `=> { … }` and function
|
|
1427
|
-
// expressions) must reduce to exactly one `return <expr>;`. Anything
|
|
1428
|
-
// with locals or multiple statements stays refused — a deferred
|
|
1429
|
-
// follow-up.
|
|
1430
|
-
let body: ts.Expression
|
|
1431
|
-
if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
|
|
1432
|
-
body = node.body
|
|
1433
|
-
} else {
|
|
1434
|
-
const block = node.body as ts.Block
|
|
1435
|
-
const stmts = block.statements
|
|
1436
|
-
if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
|
|
1437
|
-
body = stmts[0].expression
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
// Normalise the comparator body source so consumers of
|
|
1441
|
-
// `SortComparator.raw` get the same string regardless of whether
|
|
1442
|
-
// the user wrote an arrow expression (`(a, b) => a.x - b.x`) or a
|
|
1443
|
-
// block body (`(a, b) => { return a.x - b.x }`). For block bodies
|
|
1444
|
-
// this is the returned expression, not the `{ … }` block — so the
|
|
1445
|
-
// `@client` fallback's synthetic `(a, b) => raw` arrow stays valid.
|
|
1446
|
-
//
|
|
1447
|
-
// `body.getText()` resolves against the node's source file via the
|
|
1448
|
-
// parent chain — `ts.createSourceFile`-parsed nodes (the only
|
|
1449
|
-
// shape this helper accepts) carry that wiring.
|
|
1450
|
-
const raw = body.getText()
|
|
1451
|
-
|
|
1452
|
-
// A `||`-chain is a multi-key comparator: each operand is an
|
|
1453
|
-
// independent leaf applied as the next tie-breaker. A non-`||` body
|
|
1454
|
-
// is a single-key comparator (one-element chain).
|
|
1315
|
+
export function sortComparatorFromArrow(arrow: ParsedExpr): SortComparator | null {
|
|
1316
|
+
if (arrow.kind !== 'arrow' || arrow.params.length !== 2) return null
|
|
1317
|
+
const [paramA, paramB] = arrow.params
|
|
1318
|
+
|
|
1319
|
+
// A `||`-chain is a multi-key comparator: each operand is an independent
|
|
1320
|
+
// leaf applied as the next tie-breaker. A non-`||` body is single-key.
|
|
1455
1321
|
const keys: SortKey[] = []
|
|
1456
|
-
for (const operand of flattenLogicalOr(body)) {
|
|
1322
|
+
for (const operand of flattenLogicalOr(arrow.body)) {
|
|
1457
1323
|
const key = classifyLeafComparator(operand, paramA, paramB)
|
|
1458
1324
|
if (!key) return null
|
|
1459
1325
|
keys.push(key)
|
|
1460
1326
|
}
|
|
1461
1327
|
if (keys.length === 0) return null
|
|
1462
|
-
|
|
1463
|
-
return { keys, raw, paramA, paramB, method }
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
/** Strip redundant parentheses so the classifiers see the real node. */
|
|
1467
|
-
function unwrapParens(expr: ts.Expression): ts.Expression {
|
|
1468
|
-
let e = expr
|
|
1469
|
-
while (ts.isParenthesizedExpression(e)) e = e.expression
|
|
1470
|
-
return e
|
|
1328
|
+
return { keys }
|
|
1471
1329
|
}
|
|
1472
1330
|
|
|
1473
1331
|
/**
|
|
1474
|
-
* Flatten a
|
|
1475
|
-
* `a || b || c` parses as `((a || b) || c)`; this returns `[a, b, c]`.
|
|
1332
|
+
* Flatten a top-level `||` chain into its operands (`a || b || c` → `[a, b, c]`).
|
|
1476
1333
|
* A non-`||` expression returns a single-element list.
|
|
1477
1334
|
*/
|
|
1478
|
-
function flattenLogicalOr(expr:
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
return [...flattenLogicalOr(inner.left), ...flattenLogicalOr(inner.right)]
|
|
1335
|
+
function flattenLogicalOr(expr: ParsedExpr): ParsedExpr[] {
|
|
1336
|
+
if (expr.kind === 'logical' && expr.op === '||') {
|
|
1337
|
+
return [...flattenLogicalOr(expr.left), ...flattenLogicalOr(expr.right)]
|
|
1482
1338
|
}
|
|
1483
|
-
return [
|
|
1339
|
+
return [expr]
|
|
1484
1340
|
}
|
|
1485
1341
|
|
|
1486
1342
|
/**
|
|
@@ -1488,60 +1344,40 @@ function flattenLogicalOr(expr: ts.Expression): ts.Expression[] {
|
|
|
1488
1344
|
* Accepts subtraction (numeric), `localeCompare` (string), and
|
|
1489
1345
|
* relational-ternary (auto) shapes; returns null otherwise.
|
|
1490
1346
|
*/
|
|
1491
|
-
function classifyLeafComparator(
|
|
1492
|
-
expr: ts.Expression,
|
|
1493
|
-
paramA: string,
|
|
1494
|
-
paramB: string,
|
|
1495
|
-
): SortKey | null {
|
|
1496
|
-
const body = unwrapParens(expr)
|
|
1497
|
-
|
|
1347
|
+
function classifyLeafComparator(expr: ParsedExpr, paramA: string, paramB: string): SortKey | null {
|
|
1498
1348
|
// Subtraction: `a.field - b.field` / `a - b` → numeric.
|
|
1499
|
-
if (
|
|
1500
|
-
return classifyComparatorOperands(
|
|
1349
|
+
if (expr.kind === 'binary' && expr.op === '-') {
|
|
1350
|
+
return classifyComparatorOperands(expr.left, expr.right, paramA, paramB, 'numeric')
|
|
1501
1351
|
}
|
|
1502
1352
|
|
|
1503
|
-
// localeCompare (zero-arg form): `<lhs>.localeCompare(<rhs>)` →
|
|
1504
|
-
//
|
|
1505
|
-
// needs per-adapter collation plumbing (deferred follow-up).
|
|
1353
|
+
// localeCompare (zero-arg form): `<lhs>.localeCompare(<rhs>)` → string. The
|
|
1354
|
+
// locale/options form (2–3 args) stays refused.
|
|
1506
1355
|
if (
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1356
|
+
expr.kind === 'call' &&
|
|
1357
|
+
expr.callee.kind === 'member' &&
|
|
1358
|
+
expr.callee.property === 'localeCompare' &&
|
|
1359
|
+
expr.args.length === 1
|
|
1511
1360
|
) {
|
|
1512
|
-
return classifyComparatorOperands(
|
|
1513
|
-
body.expression.expression, // receiver of .localeCompare
|
|
1514
|
-
body.arguments[0],
|
|
1515
|
-
paramA,
|
|
1516
|
-
paramB,
|
|
1517
|
-
'string',
|
|
1518
|
-
)
|
|
1361
|
+
return classifyComparatorOperands(expr.callee.object, expr.args[0], paramA, paramB, 'string')
|
|
1519
1362
|
}
|
|
1520
1363
|
|
|
1521
1364
|
// Relational-ternary sign comparator → auto.
|
|
1522
|
-
if (
|
|
1523
|
-
return classifyTernaryComparator(
|
|
1365
|
+
if (expr.kind === 'conditional') {
|
|
1366
|
+
return classifyTernaryComparator(expr, paramA, paramB)
|
|
1524
1367
|
}
|
|
1525
1368
|
|
|
1526
1369
|
return null
|
|
1527
1370
|
}
|
|
1528
1371
|
|
|
1529
1372
|
/**
|
|
1530
|
-
* Classify two operands against the comparator's two param names.
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
*
|
|
1534
|
-
* The two operands must reference different params (one paramA, one
|
|
1535
|
-
* paramB) and match on key shape + field name. Order of the params
|
|
1536
|
-
* determines `direction`: `paramA` first is ascending, reversed is
|
|
1537
|
-
* descending.
|
|
1538
|
-
*
|
|
1539
|
-
* Anything deeper (chained `.x.y`, computed `.[i]`, calls, literals)
|
|
1540
|
-
* or mismatched keys returns null.
|
|
1373
|
+
* Classify two operands against the comparator's two param names. Both must
|
|
1374
|
+
* resolve to either the param identifier itself (`self`) or a single-level
|
|
1375
|
+
* field access on it (`field`), reference different params, and match on key
|
|
1376
|
+
* shape + field name. `paramA` first is ascending, reversed is descending.
|
|
1541
1377
|
*/
|
|
1542
1378
|
function classifyComparatorOperands(
|
|
1543
|
-
left:
|
|
1544
|
-
right:
|
|
1379
|
+
left: ParsedExpr,
|
|
1380
|
+
right: ParsedExpr,
|
|
1545
1381
|
paramA: string,
|
|
1546
1382
|
paramB: string,
|
|
1547
1383
|
type: 'numeric' | 'string',
|
|
@@ -1560,44 +1396,35 @@ function classifyComparatorOperands(
|
|
|
1560
1396
|
|
|
1561
1397
|
/**
|
|
1562
1398
|
* Classify a relational-ternary comparator leaf into an `auto` SortKey.
|
|
1563
|
-
* Handles the 2-way sign form (`a.f > b.f ? 1 : -1`), the canonical
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
* Direction is derived from (relational op, operand order, sign of the
|
|
1568
|
-
* `whenTrue` branch); the `whenFalse` branch only needs to be a bounded
|
|
1569
|
-
* shape (sign literal or a nested ternary on the same key) so we don't
|
|
1570
|
-
* silently accept arbitrary expressions.
|
|
1399
|
+
* Handles the 2-way sign form (`a.f > b.f ? 1 : -1`), the canonical 3-way
|
|
1400
|
+
* (`a.f < b.f ? -1 : a.f > b.f ? 1 : 0`), and a leading equality tie
|
|
1401
|
+
* (`a.f === b.f ? 0 : <relational ternary>`).
|
|
1571
1402
|
*/
|
|
1572
1403
|
function classifyTernaryComparator(
|
|
1573
|
-
node:
|
|
1404
|
+
node: Extract<ParsedExpr, { kind: 'conditional' }>,
|
|
1574
1405
|
paramA: string,
|
|
1575
1406
|
paramB: string,
|
|
1576
1407
|
): SortKey | null {
|
|
1577
|
-
const cond =
|
|
1408
|
+
const cond = node.test
|
|
1578
1409
|
|
|
1579
|
-
// Leading equality tie: `a.f === b.f ? 0 : <ternary>`.
|
|
1580
|
-
// arm returns 0 (tie); the real ordering lives in the else branch.
|
|
1410
|
+
// Leading equality tie: `a.f === b.f ? 0 : <ternary>`.
|
|
1581
1411
|
if (
|
|
1582
|
-
|
|
1583
|
-
(cond.
|
|
1584
|
-
cond.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken) &&
|
|
1412
|
+
cond.kind === 'binary' &&
|
|
1413
|
+
(cond.op === '===' || cond.op === '==') &&
|
|
1585
1414
|
sameKeyOperands(cond.left, cond.right, paramA, paramB) &&
|
|
1586
|
-
numericSign(node.
|
|
1415
|
+
numericSign(node.consequent) === 0
|
|
1587
1416
|
) {
|
|
1588
|
-
const elseBranch =
|
|
1589
|
-
if (
|
|
1417
|
+
const elseBranch = node.alternate
|
|
1418
|
+
if (elseBranch.kind === 'conditional') {
|
|
1590
1419
|
return classifyTernaryComparator(elseBranch, paramA, paramB)
|
|
1591
1420
|
}
|
|
1592
1421
|
return null
|
|
1593
1422
|
}
|
|
1594
1423
|
|
|
1595
1424
|
// Relational condition: `<left> <op> <right>` with op ∈ {<,>,<=,>=}.
|
|
1596
|
-
if (
|
|
1597
|
-
const
|
|
1598
|
-
const
|
|
1599
|
-
op === ts.SyntaxKind.GreaterThanToken || op === ts.SyntaxKind.GreaterThanEqualsToken
|
|
1600
|
-
const isLess = op === ts.SyntaxKind.LessThanToken || op === ts.SyntaxKind.LessThanEqualsToken
|
|
1425
|
+
if (cond.kind !== 'binary') return null
|
|
1426
|
+
const isGreater = cond.op === '>' || cond.op === '>='
|
|
1427
|
+
const isLess = cond.op === '<' || cond.op === '<='
|
|
1601
1428
|
if (!isGreater && !isLess) return null
|
|
1602
1429
|
|
|
1603
1430
|
const leftRef = classifySortOperand(cond.left, paramA, paramB)
|
|
@@ -1609,37 +1436,21 @@ function classifyTernaryComparator(
|
|
|
1609
1436
|
return null
|
|
1610
1437
|
}
|
|
1611
1438
|
|
|
1612
|
-
// whenTrue must be a non-zero sign literal (±1); whenFalse a bounded
|
|
1613
|
-
//
|
|
1614
|
-
//
|
|
1615
|
-
|
|
1616
|
-
// whenFalse branch is only validated for key agreement, not direction
|
|
1617
|
-
// consistency. A contradictory hand-written 3-way (e.g.
|
|
1618
|
-
// `a.f < b.f ? -1 : a.f < b.f ? 1 : 0`) is therefore lowered per the
|
|
1619
|
-
// outer comparison; the JS-runtime (Hono/CSR) path runs the literal
|
|
1620
|
-
// body, so such a degenerate comparator could order differently
|
|
1621
|
-
// there. The canonical asc/desc 3-way forms agree on both paths.
|
|
1622
|
-
const trueSign = numericSign(node.whenTrue)
|
|
1439
|
+
// whenTrue must be a non-zero sign literal (±1); whenFalse a bounded shape
|
|
1440
|
+
// (sign literal or a nested ternary on the same key). Direction is derived
|
|
1441
|
+
// solely from this outer comparison.
|
|
1442
|
+
const trueSign = numericSign(node.consequent)
|
|
1623
1443
|
if (trueSign === null || trueSign === 0) return null
|
|
1624
|
-
if (!isBoundedTernaryElse(node.
|
|
1444
|
+
if (!isBoundedTernaryElse(node.alternate, leftRef.key, paramA, paramB)) return null
|
|
1625
1445
|
|
|
1626
1446
|
// Rewrite so the condition reads as `aKey <op> bKey` (paramA left).
|
|
1627
|
-
// `b.f > a.f` ⇔ `a.f < b.f`, so a paramB-on-left operand flips it.
|
|
1628
1447
|
const greaterForA = leftRef.param === 'A' ? isGreater : !isGreater
|
|
1629
|
-
|
|
1630
|
-
// `a.f > b.f ? +n` → bigger sorts later → ascending
|
|
1631
|
-
// `a.f < b.f ? +n` → bigger sorts earlier → descending
|
|
1632
1448
|
const asc = greaterForA ? trueSign > 0 : trueSign < 0
|
|
1633
1449
|
return { key: leftRef.key, type: 'auto', direction: asc ? 'asc' : 'desc' }
|
|
1634
1450
|
}
|
|
1635
1451
|
|
|
1636
1452
|
/** True when both operands resolve to the same key on opposite params. */
|
|
1637
|
-
function sameKeyOperands(
|
|
1638
|
-
left: ts.Expression,
|
|
1639
|
-
right: ts.Expression,
|
|
1640
|
-
paramA: string,
|
|
1641
|
-
paramB: string,
|
|
1642
|
-
): boolean {
|
|
1453
|
+
function sameKeyOperands(left: ParsedExpr, right: ParsedExpr, paramA: string, paramB: string): boolean {
|
|
1643
1454
|
const l = classifySortOperand(left, paramA, paramB)
|
|
1644
1455
|
const r = classifySortOperand(right, paramA, paramB)
|
|
1645
1456
|
if (!l || !r) return false
|
|
@@ -1650,17 +1461,16 @@ function sameKeyOperands(
|
|
|
1650
1461
|
}
|
|
1651
1462
|
|
|
1652
1463
|
/**
|
|
1653
|
-
* Sign of a numeric literal with optional unary minus: 1, -1, or 0.
|
|
1654
|
-
*
|
|
1464
|
+
* Sign of a numeric literal with optional unary minus: 1, -1, or 0. Returns
|
|
1465
|
+
* null for anything that isn't a (signed) numeric literal.
|
|
1655
1466
|
*/
|
|
1656
|
-
function numericSign(expr:
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
const inner = numericSign(e.operand)
|
|
1467
|
+
function numericSign(expr: ParsedExpr): number | null {
|
|
1468
|
+
if (expr.kind === 'unary' && expr.op === '-') {
|
|
1469
|
+
const inner = numericSign(expr.argument)
|
|
1660
1470
|
return inner === null ? null : -inner
|
|
1661
1471
|
}
|
|
1662
|
-
if (
|
|
1663
|
-
const n =
|
|
1472
|
+
if (expr.kind === 'literal' && expr.literalType === 'number' && typeof expr.value === 'number') {
|
|
1473
|
+
const n = expr.value
|
|
1664
1474
|
if (Number.isNaN(n)) return null
|
|
1665
1475
|
if (n === 0) return 0
|
|
1666
1476
|
return n > 0 ? 1 : -1
|
|
@@ -1669,21 +1479,18 @@ function numericSign(expr: ts.Expression): number | null {
|
|
|
1669
1479
|
}
|
|
1670
1480
|
|
|
1671
1481
|
/**
|
|
1672
|
-
* The `whenFalse` arm of a relational ternary is bounded if it's a
|
|
1673
|
-
*
|
|
1674
|
-
* canonical 3-way form). The outer comparison already fixes direction,
|
|
1675
|
-
* so the nested branch only needs to agree on which key it compares.
|
|
1482
|
+
* The `whenFalse` arm of a relational ternary is bounded if it's a sign
|
|
1483
|
+
* literal (±1 / 0) or a nested ternary on the same key (the canonical 3-way).
|
|
1676
1484
|
*/
|
|
1677
1485
|
function isBoundedTernaryElse(
|
|
1678
|
-
expr:
|
|
1486
|
+
expr: ParsedExpr,
|
|
1679
1487
|
key: { kind: 'self' } | { kind: 'field'; field: string },
|
|
1680
1488
|
paramA: string,
|
|
1681
1489
|
paramB: string,
|
|
1682
1490
|
): boolean {
|
|
1683
|
-
|
|
1684
|
-
if (
|
|
1685
|
-
|
|
1686
|
-
const nested = classifyTernaryComparator(e, paramA, paramB)
|
|
1491
|
+
if (numericSign(expr) !== null) return true
|
|
1492
|
+
if (expr.kind === 'conditional') {
|
|
1493
|
+
const nested = classifyTernaryComparator(expr, paramA, paramB)
|
|
1687
1494
|
return nested !== null && sortKeyEquals(nested.key, key)
|
|
1688
1495
|
}
|
|
1689
1496
|
return false
|
|
@@ -1698,238 +1505,28 @@ function sortKeyEquals(
|
|
|
1698
1505
|
return true
|
|
1699
1506
|
}
|
|
1700
1507
|
|
|
1508
|
+
/**
|
|
1509
|
+
* Resolve a sort operand to a `self` / `field` key on param A or B. A bare
|
|
1510
|
+
* param identifier is `self`; a single-level non-computed field access on a
|
|
1511
|
+
* param is `field`. Anything deeper returns null.
|
|
1512
|
+
*/
|
|
1701
1513
|
function classifySortOperand(
|
|
1702
|
-
expr:
|
|
1514
|
+
expr: ParsedExpr,
|
|
1703
1515
|
paramA: string,
|
|
1704
1516
|
paramB: string,
|
|
1705
1517
|
): { key: { kind: 'self' } | { kind: 'field'; field: string }; param: 'A' | 'B' } | null {
|
|
1706
|
-
if (
|
|
1707
|
-
if (expr.
|
|
1708
|
-
if (expr.
|
|
1518
|
+
if (expr.kind === 'identifier') {
|
|
1519
|
+
if (expr.name === paramA) return { key: { kind: 'self' }, param: 'A' }
|
|
1520
|
+
if (expr.name === paramB) return { key: { kind: 'self' }, param: 'B' }
|
|
1709
1521
|
return null
|
|
1710
1522
|
}
|
|
1711
|
-
if (
|
|
1712
|
-
if (expr.
|
|
1713
|
-
|
|
1714
|
-
}
|
|
1715
|
-
if (expr.expression.text === paramB) {
|
|
1716
|
-
return { key: { kind: 'field', field: expr.name.text }, param: 'B' }
|
|
1717
|
-
}
|
|
1523
|
+
if (expr.kind === 'member' && !expr.computed && expr.object.kind === 'identifier') {
|
|
1524
|
+
if (expr.object.name === paramA) return { key: { kind: 'field', field: expr.property }, param: 'A' }
|
|
1525
|
+
if (expr.object.name === paramB) return { key: { kind: 'field', field: expr.property }, param: 'B' }
|
|
1718
1526
|
}
|
|
1719
1527
|
return null
|
|
1720
1528
|
}
|
|
1721
1529
|
|
|
1722
|
-
/**
|
|
1723
|
-
* Recover a `ReduceOp` from the `(reducer, init)` args of
|
|
1724
|
-
* `.reduce(...)` (#1448 Tier C). Operates on the raw TS AST because the
|
|
1725
|
-
* standard `convertNode` arrow-fn path rejects two-param arrows.
|
|
1726
|
-
*
|
|
1727
|
-
* The accepted catalogue is intentionally finite — only the
|
|
1728
|
-
* arithmetic-fold family lowers to a declarative template:
|
|
1729
|
-
*
|
|
1730
|
-
* (acc, x) => acc + x → self, numeric (init: number)
|
|
1731
|
-
* (acc, x) => acc + x.field → field, numeric (init: number)
|
|
1732
|
-
* (acc, x) => acc * x → self, numeric (init: number)
|
|
1733
|
-
* (acc, x) => acc * x.field → field, numeric (init: number)
|
|
1734
|
-
* (acc, x) => acc + x → self, string (init: string → concat)
|
|
1735
|
-
* (acc, x) => acc + x.field → field, string (init: string → concat)
|
|
1736
|
-
*
|
|
1737
|
-
* The accumulator must be the binary expression's *left* operand
|
|
1738
|
-
* (canonical reduce form; reversed operands change string-concat
|
|
1739
|
-
* order), the per-item value must be the item param itself or a
|
|
1740
|
-
* single non-computed field access on it, and the init must be a
|
|
1741
|
-
* number or string literal (negative numbers via prefix `-` allowed).
|
|
1742
|
-
* String concatenation requires `+`. Block bodies reduce to a single
|
|
1743
|
-
* `return`, mirroring the sort extractor. Anything else returns null
|
|
1744
|
-
* and the caller emits `unsupported` (BF101).
|
|
1745
|
-
*/
|
|
1746
|
-
export function extractReduceOpFromTS(
|
|
1747
|
-
reducerNode: ts.Node,
|
|
1748
|
-
initNode: ts.Node,
|
|
1749
|
-
): ReduceOp | null {
|
|
1750
|
-
const init = classifyReduceInit(initNode)
|
|
1751
|
-
if (!init) return null
|
|
1752
|
-
|
|
1753
|
-
if (!ts.isArrowFunction(reducerNode) && !ts.isFunctionExpression(reducerNode)) return null
|
|
1754
|
-
// Exactly `(acc, item)` — the index / array reducer params can't be
|
|
1755
|
-
// expressed in a template fold, so refuse the 3- / 4-param forms.
|
|
1756
|
-
if (reducerNode.parameters.length !== 2) return null
|
|
1757
|
-
const pAcc = reducerNode.parameters[0]
|
|
1758
|
-
const pItem = reducerNode.parameters[1]
|
|
1759
|
-
if (!ts.isIdentifier(pAcc.name) || !ts.isIdentifier(pItem.name)) return null
|
|
1760
|
-
const paramAcc = pAcc.name.text
|
|
1761
|
-
const paramItem = pItem.name.text
|
|
1762
|
-
|
|
1763
|
-
// Resolve the reducer body: expression-bodied arrow directly; block
|
|
1764
|
-
// bodies (arrow `=> { … }` and function expressions) must reduce to
|
|
1765
|
-
// exactly one `return <expr>;` — mirrors `extractSortComparatorFromTS`.
|
|
1766
|
-
let body: ts.Expression
|
|
1767
|
-
if (ts.isArrowFunction(reducerNode) && !ts.isBlock(reducerNode.body)) {
|
|
1768
|
-
body = reducerNode.body
|
|
1769
|
-
} else {
|
|
1770
|
-
const block = reducerNode.body as ts.Block
|
|
1771
|
-
const stmts = block.statements
|
|
1772
|
-
if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
|
|
1773
|
-
body = stmts[0].expression
|
|
1774
|
-
}
|
|
1775
|
-
const raw = body.getText()
|
|
1776
|
-
|
|
1777
|
-
const expr = unwrapParens(body)
|
|
1778
|
-
if (!ts.isBinaryExpression(expr)) return null
|
|
1779
|
-
let op: '+' | '*'
|
|
1780
|
-
if (expr.operatorToken.kind === ts.SyntaxKind.PlusToken) op = '+'
|
|
1781
|
-
else if (expr.operatorToken.kind === ts.SyntaxKind.AsteriskToken) op = '*'
|
|
1782
|
-
else return null
|
|
1783
|
-
|
|
1784
|
-
// The accumulator must be the left operand (`acc + x`, not `x + acc`).
|
|
1785
|
-
const left = unwrapParens(expr.left)
|
|
1786
|
-
if (!ts.isIdentifier(left) || left.text !== paramAcc) return null
|
|
1787
|
-
|
|
1788
|
-
const key = classifyReduceKey(unwrapParens(expr.right), paramItem)
|
|
1789
|
-
if (!key) return null
|
|
1790
|
-
|
|
1791
|
-
// String concatenation only makes sense with `+`.
|
|
1792
|
-
const type: 'numeric' | 'string' = init.type
|
|
1793
|
-
if (type === 'string' && op !== '+') return null
|
|
1794
|
-
|
|
1795
|
-
return { op, key, type, init: init.value, raw, paramAcc, paramItem }
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
/**
|
|
1799
|
-
* Recover a `FlatMapOp` from the single-argument callback of a
|
|
1800
|
-
* value-returning `.flatMap(fn)` (#1448 Tier C). Operates on the raw TS
|
|
1801
|
-
* AST, mirroring `extractReduceOpFromTS`.
|
|
1802
|
-
*
|
|
1803
|
-
* The accepted catalogue:
|
|
1804
|
-
*
|
|
1805
|
-
* i => i → self (flatMap(identity) === flat(1))
|
|
1806
|
-
* i => i.field → field (flatten a per-item array field)
|
|
1807
|
-
* i => [i.a, i.b] → tuple (gather per-item self / field leaves)
|
|
1808
|
-
*
|
|
1809
|
-
* The callback must take exactly one identifier param (the index / array
|
|
1810
|
-
* params can't be expressed in a template projection), and the body must
|
|
1811
|
-
* be the param itself, a single non-computed field access on it, or an
|
|
1812
|
-
* array literal whose every element is one of those leaves. Block bodies
|
|
1813
|
-
* reduce to a single `return`, like the reduce / sort extractors. Any
|
|
1814
|
-
* other body (deep access, computed members, calls, arithmetic, a
|
|
1815
|
-
* literal element) returns null and the caller emits `unsupported`
|
|
1816
|
-
* (BF101).
|
|
1817
|
-
*/
|
|
1818
|
-
export function extractFlatMapOpFromTS(cbNode: ts.Node): FlatMapOp | null {
|
|
1819
|
-
if (!ts.isArrowFunction(cbNode) && !ts.isFunctionExpression(cbNode)) return null
|
|
1820
|
-
// Exactly `(item)` — a `(item, index)` / `(item, index, array)` callback
|
|
1821
|
-
// can't be lowered to a declarative projection.
|
|
1822
|
-
if (cbNode.parameters.length !== 1) return null
|
|
1823
|
-
const p = cbNode.parameters[0]
|
|
1824
|
-
if (!ts.isIdentifier(p.name)) return null
|
|
1825
|
-
const param = p.name.text
|
|
1826
|
-
|
|
1827
|
-
let body: ts.Expression
|
|
1828
|
-
if (ts.isArrowFunction(cbNode) && !ts.isBlock(cbNode.body)) {
|
|
1829
|
-
body = cbNode.body
|
|
1830
|
-
} else {
|
|
1831
|
-
const block = cbNode.body as ts.Block
|
|
1832
|
-
const stmts = block.statements
|
|
1833
|
-
if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
|
|
1834
|
-
body = stmts[0].expression
|
|
1835
|
-
}
|
|
1836
|
-
const raw = body.getText()
|
|
1837
|
-
const inner = unwrapParens(body)
|
|
1838
|
-
|
|
1839
|
-
// Array-literal body → tuple projection. Every element must be a
|
|
1840
|
-
// self / field leaf; a literal / computed / nested element refuses the
|
|
1841
|
-
// whole shape (the per-item evaluation of richer expressions isn't
|
|
1842
|
-
// lowered). flat(1) removes only the literal's wrapper, so each leaf is
|
|
1843
|
-
// appended verbatim — handled by the `bf_flat_map_tuple` runtime.
|
|
1844
|
-
if (ts.isArrayLiteralExpression(inner)) {
|
|
1845
|
-
// An empty tuple (`i => []`) is a degenerate no-op projection (always
|
|
1846
|
-
// yields nothing). Refuse it so the emitters never produce a
|
|
1847
|
-
// zero-arg `bf_flat_map_tuple` / `bf->flat_map_tuple(...,)` call.
|
|
1848
|
-
if (inner.elements.length === 0) return null
|
|
1849
|
-
const elements: FlatMapLeaf[] = []
|
|
1850
|
-
for (const el of inner.elements) {
|
|
1851
|
-
// Spread / holes (`[...xs]`, `[, x]`) aren't leaves.
|
|
1852
|
-
if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) return null
|
|
1853
|
-
const leaf = classifyReduceKey(unwrapParens(el), param)
|
|
1854
|
-
if (!leaf) return null
|
|
1855
|
-
elements.push(leaf)
|
|
1856
|
-
}
|
|
1857
|
-
return { projection: { kind: 'tuple', elements }, param, raw }
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
// Scalar body. Reuse the reduce key classifier — `i` → self,
|
|
1861
|
-
// `i.field` → field, null for anything deeper (`i.a.b`, `i[k]`, a call).
|
|
1862
|
-
const leaf = classifyReduceKey(inner, param)
|
|
1863
|
-
if (!leaf) return null
|
|
1864
|
-
|
|
1865
|
-
return { projection: leaf, param, raw }
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
/**
|
|
1869
|
-
* Classify a reduce per-item operand into a `ReduceOp` key. Accepts
|
|
1870
|
-
* the bare item param (`x` → self) and a single non-computed field
|
|
1871
|
-
* access (`x.field` → field); returns null for anything deeper
|
|
1872
|
-
* (`x.a.b`, `x[k]`, a literal, a call, …).
|
|
1873
|
-
*/
|
|
1874
|
-
function classifyReduceKey(
|
|
1875
|
-
expr: ts.Expression,
|
|
1876
|
-
paramItem: string,
|
|
1877
|
-
): { kind: 'self' } | { kind: 'field'; field: string } | null {
|
|
1878
|
-
if (ts.isIdentifier(expr)) {
|
|
1879
|
-
return expr.text === paramItem ? { kind: 'self' } : null
|
|
1880
|
-
}
|
|
1881
|
-
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) {
|
|
1882
|
-
if (expr.expression.text === paramItem) return { kind: 'field', field: expr.name.text }
|
|
1883
|
-
}
|
|
1884
|
-
return null
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
/**
|
|
1888
|
-
* Classify a reduce initial-value node into the *decoded* fold seed.
|
|
1889
|
-
* Accepts a numeric literal (optionally prefixed with `-`) and a string
|
|
1890
|
-
* literal; returns `{ type, value }` where `value` is the canonical
|
|
1891
|
-
* value — never the raw source text. Any other init (a variable, a
|
|
1892
|
-
* call, an object) returns null — the fold start value must be
|
|
1893
|
-
* statically known.
|
|
1894
|
-
*
|
|
1895
|
-
* Numeric: `node.text` is TypeScript's canonical decimal form, so
|
|
1896
|
-
* separators and non-decimal radices fold uniformly across adapters
|
|
1897
|
-
* (`1_000` → `1000`, `0x10` → `16`, `1e3` → `1000`). The Go runtime's
|
|
1898
|
-
* `strconv.ParseFloat` and Perl both accept that decimal string —
|
|
1899
|
-
* passing the raw source (`0x10`, `1_000`) would silently fold to 0 on
|
|
1900
|
-
* Go while Perl accepted it (#1728 review).
|
|
1901
|
-
*
|
|
1902
|
-
* String: `node.text` is the *unescaped* contents. To keep the three
|
|
1903
|
-
* adapters byte-equal without teaching each one to re-decode JS escapes
|
|
1904
|
-
* (`\n`, `\u{…}`, `\\`, an escaped quote), we refuse any string literal
|
|
1905
|
-
* whose contents differ from its raw inner source — i.e. any literal
|
|
1906
|
-
* carrying an escape sequence. Accepted seeds are therefore escape-free
|
|
1907
|
-
* single-line strings (`''`, `', '`, `'-'`), which embed safely in both
|
|
1908
|
-
* the Go-template `"…"` operand and the Perl single-quoted literal. The
|
|
1909
|
-
* realistic concat seed (`''`) is unaffected; richer seeds fall back to
|
|
1910
|
-
* the `@client` escape hatch.
|
|
1911
|
-
*/
|
|
1912
|
-
function classifyReduceInit(
|
|
1913
|
-
node: ts.Node,
|
|
1914
|
-
): { type: 'numeric' | 'string'; value: string } | null {
|
|
1915
|
-
// Unwrap redundant parens (`(0)` / `(-1)`) so they classify like the
|
|
1916
|
-
// bare literal — matches the extractor's `unwrapParens` use elsewhere.
|
|
1917
|
-
let n: ts.Node = unwrapParens(node as ts.Expression)
|
|
1918
|
-
// `-1` parses as a prefix-minus over a numeric literal.
|
|
1919
|
-
if (ts.isPrefixUnaryExpression(n) && n.operator === ts.SyntaxKind.MinusToken) {
|
|
1920
|
-
if (ts.isNumericLiteral(n.operand)) return { type: 'numeric', value: '-' + n.operand.text }
|
|
1921
|
-
return null
|
|
1922
|
-
}
|
|
1923
|
-
if (ts.isNumericLiteral(n)) return { type: 'numeric', value: n.text }
|
|
1924
|
-
if (ts.isStringLiteral(n)) {
|
|
1925
|
-
// Refuse literals carrying escapes so the decoded value equals its
|
|
1926
|
-
// raw inner source (and thus embeds safely + byte-equal everywhere).
|
|
1927
|
-
const raw = n.getText()
|
|
1928
|
-
if (raw.length < 2 || raw.slice(1, -1) !== n.text) return null
|
|
1929
|
-
return { type: 'string', value: n.text }
|
|
1930
|
-
}
|
|
1931
|
-
return null
|
|
1932
|
-
}
|
|
1933
1530
|
|
|
1934
1531
|
/**
|
|
1935
1532
|
* Per-binding entry stored in `fieldMap`: the dotted path from the
|
|
@@ -2070,8 +1667,12 @@ function collectDestructureBindings(
|
|
|
2070
1667
|
let defaultExpr: ParsedExpr | undefined
|
|
2071
1668
|
if (el.initializer) {
|
|
2072
1669
|
const parsed = convertNode(el.initializer, raw)
|
|
2073
|
-
|
|
2074
|
-
|
|
1670
|
+
// An object-literal default isn't lowered into a destructured filter
|
|
1671
|
+
// predicate yet — refuse it exactly as before the kind existed, with
|
|
1672
|
+
// the same reason text the `unsupported` fallback produced (A-1).
|
|
1673
|
+
if (parsed.kind === 'unsupported' || parsed.kind === 'object-literal') {
|
|
1674
|
+
const reason = parsed.kind === 'unsupported' ? parsed.reason : 'Unsupported syntax: ObjectLiteralExpression'
|
|
1675
|
+
return { ok: false, reason: `Default value in destructured filter param failed to parse: ${reason}` }
|
|
2075
1676
|
}
|
|
2076
1677
|
defaultExpr = parsed
|
|
2077
1678
|
}
|
|
@@ -2114,6 +1715,7 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
|
|
|
2114
1715
|
case 'literal':
|
|
2115
1716
|
case 'identifier':
|
|
2116
1717
|
case 'unsupported':
|
|
1718
|
+
case 'object-literal':
|
|
2117
1719
|
return null
|
|
2118
1720
|
case 'member':
|
|
2119
1721
|
return findImpureDefaultNode(expr.object)
|
|
@@ -2144,8 +1746,8 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
|
|
|
2144
1746
|
return null
|
|
2145
1747
|
case 'call':
|
|
2146
1748
|
case 'array-method':
|
|
2147
|
-
case '
|
|
2148
|
-
case '
|
|
1749
|
+
case 'arrow':
|
|
1750
|
+
case 'regex':
|
|
2149
1751
|
return expr.kind
|
|
2150
1752
|
}
|
|
2151
1753
|
}
|
|
@@ -2249,19 +1851,13 @@ function validateRestUsage(
|
|
|
2249
1851
|
if (part.type === 'expression') walk(part.expr)
|
|
2250
1852
|
}
|
|
2251
1853
|
return
|
|
2252
|
-
case 'arrow
|
|
2253
|
-
// Inner arrow that re-uses `restName` as its own
|
|
2254
|
-
// shadows the outer rest binding — references inside
|
|
2255
|
-
// body belong to the inner param, not us (#1532 review).
|
|
2256
|
-
if (e.
|
|
1854
|
+
case 'arrow':
|
|
1855
|
+
// Inner arrow that re-uses `restName` as one of its own
|
|
1856
|
+
// parameters shadows the outer rest binding — references inside
|
|
1857
|
+
// its body belong to the inner param, not us (#1532 review).
|
|
1858
|
+
if (e.params.includes(restName)) return
|
|
2257
1859
|
walk(e.body)
|
|
2258
1860
|
return
|
|
2259
|
-
case 'higher-order':
|
|
2260
|
-
walk(e.object)
|
|
2261
|
-
// The predicate is the inner-callback body; its `param`
|
|
2262
|
-
// shadows the outer rest binding when names match.
|
|
2263
|
-
if (e.param !== restName) walk(e.predicate)
|
|
2264
|
-
return
|
|
2265
1861
|
case 'array-literal':
|
|
2266
1862
|
for (const el of e.elements) walk(el)
|
|
2267
1863
|
return
|
|
@@ -2271,6 +1867,7 @@ function validateRestUsage(
|
|
|
2271
1867
|
return
|
|
2272
1868
|
case 'literal':
|
|
2273
1869
|
case 'unsupported':
|
|
1870
|
+
case 'object-literal':
|
|
2274
1871
|
return
|
|
2275
1872
|
}
|
|
2276
1873
|
}
|
|
@@ -2370,13 +1967,9 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
|
|
|
2370
1967
|
if (part.type === 'expression') collectIdentifiers(part.expr, out)
|
|
2371
1968
|
}
|
|
2372
1969
|
return
|
|
2373
|
-
case 'arrow
|
|
1970
|
+
case 'arrow':
|
|
2374
1971
|
collectIdentifiers(expr.body, out)
|
|
2375
1972
|
return
|
|
2376
|
-
case 'higher-order':
|
|
2377
|
-
collectIdentifiers(expr.object, out)
|
|
2378
|
-
collectIdentifiers(expr.predicate, out)
|
|
2379
|
-
return
|
|
2380
1973
|
case 'array-literal':
|
|
2381
1974
|
expr.elements.forEach(e => collectIdentifiers(e, out))
|
|
2382
1975
|
return
|
|
@@ -2385,7 +1978,11 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
|
|
|
2385
1978
|
expr.args.forEach(e => collectIdentifiers(e, out))
|
|
2386
1979
|
return
|
|
2387
1980
|
case 'literal':
|
|
1981
|
+
case 'regex':
|
|
2388
1982
|
case 'unsupported':
|
|
1983
|
+
// Mirror `unsupported`: an object literal was not carried before this
|
|
1984
|
+
// kind existed, so it collects no identifiers (byte-identical A-1).
|
|
1985
|
+
case 'object-literal':
|
|
2389
1986
|
return
|
|
2390
1987
|
}
|
|
2391
1988
|
}
|
|
@@ -2463,49 +2060,29 @@ function substituteDestructuredFields(
|
|
|
2463
2060
|
p.type === 'expression' ? { type: 'expression', expr: walk(p.expr) } : p,
|
|
2464
2061
|
),
|
|
2465
2062
|
}
|
|
2466
|
-
case 'arrow
|
|
2063
|
+
case 'arrow':
|
|
2467
2064
|
// A nested arrow inside the predicate body shadows the outer
|
|
2468
|
-
// param
|
|
2469
|
-
//
|
|
2470
|
-
//
|
|
2471
|
-
//
|
|
2472
|
-
//
|
|
2473
|
-
// `done` only if it doesn't shadow it, which we can't know
|
|
2474
|
-
// without per-scope tracking). Skipping the rewrite is the
|
|
2475
|
-
// conservative choice — worst case the resulting predicate
|
|
2476
|
-
// doesn't lower and the adapter emits BF101.
|
|
2477
|
-
return e
|
|
2478
|
-
case 'higher-order':
|
|
2065
|
+
// param (a higher-order callback like `.sort(cmp)` / `.filter(p)`
|
|
2066
|
+
// reaches here as the arrow argument of a generic `call`). Leave
|
|
2067
|
+
// its body alone — its comparator/predicate references its own
|
|
2068
|
+
// params, never the enclosing destructure. Skipping the rewrite is
|
|
2069
|
+
// the conservative choice; worst case the adapter emits BF101.
|
|
2479
2070
|
return e
|
|
2480
2071
|
case 'array-literal':
|
|
2481
2072
|
return { kind: 'array-literal', elements: e.elements.map(walk) }
|
|
2482
2073
|
case 'array-method':
|
|
2483
|
-
if (e.method === 'sort' || e.method === 'toSorted') {
|
|
2484
|
-
// Sort comparator is a structured value, not a ParsedExpr —
|
|
2485
|
-
// destructured-field substitution doesn't apply (the
|
|
2486
|
-
// comparator references its own paramA / paramB, never the
|
|
2487
|
-
// enclosing destructure). Preserve verbatim.
|
|
2488
|
-
return { kind: 'array-method', method: e.method, object: walk(e.object), args: [], comparator: e.comparator }
|
|
2489
|
-
}
|
|
2490
|
-
if (e.method === 'reduce' || e.method === 'reduceRight') {
|
|
2491
|
-
// `ReduceOp` is a structured value referencing its own
|
|
2492
|
-
// paramAcc / paramItem, never the enclosing destructure —
|
|
2493
|
-
// preserve verbatim, same as the sort comparator above.
|
|
2494
|
-
return { kind: 'array-method', method: e.method, object: walk(e.object), args: [], reduceOp: e.reduceOp }
|
|
2495
|
-
}
|
|
2496
2074
|
if (e.method === 'flat') {
|
|
2497
2075
|
// `flatDepth` is a normalised literal — no destructure refs to
|
|
2498
|
-
// substitute. Preserve verbatim
|
|
2076
|
+
// substitute. Preserve verbatim.
|
|
2499
2077
|
return { kind: 'array-method', method: 'flat', object: walk(e.object), args: [], flatDepth: e.flatDepth }
|
|
2500
2078
|
}
|
|
2501
|
-
if (e.method === 'flatMap') {
|
|
2502
|
-
// `FlatMapOp` references its own callback param, never the
|
|
2503
|
-
// enclosing destructure — preserve verbatim, like reduce / sort.
|
|
2504
|
-
return { kind: 'array-method', method: 'flatMap', object: walk(e.object), args: [], flatMapOp: e.flatMapOp }
|
|
2505
|
-
}
|
|
2506
2079
|
return { kind: 'array-method', method: e.method, object: walk(e.object), args: e.args.map(walk) }
|
|
2507
2080
|
case 'literal':
|
|
2081
|
+
case 'regex':
|
|
2508
2082
|
case 'unsupported':
|
|
2083
|
+
// Mirror `unsupported`: object literals were not substituted into
|
|
2084
|
+
// before this kind existed — return verbatim (byte-identical A-1).
|
|
2085
|
+
case 'object-literal':
|
|
2509
2086
|
return e
|
|
2510
2087
|
}
|
|
2511
2088
|
}
|
|
@@ -2567,57 +2144,27 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
2567
2144
|
case 'unsupported':
|
|
2568
2145
|
return { supported: false, reason: expr.reason }
|
|
2569
2146
|
|
|
2147
|
+
// A bare object literal is still refused as a standalone template
|
|
2148
|
+
// expression — adapters that lower one as a *value* (Go map / Perl
|
|
2149
|
+
// hashref) do so in their own emitter, like `array-literal`, not
|
|
2150
|
+
// through this support gate. The reason string is the exact text the
|
|
2151
|
+
// `unsupported` fallback produced before the `object-literal` kind
|
|
2152
|
+
// existed, so diagnostics stay byte-identical (Roadmap A-1).
|
|
2153
|
+
case 'object-literal':
|
|
2154
|
+
return { supported: false, reason: 'Unsupported syntax: ObjectLiteralExpression' }
|
|
2155
|
+
|
|
2570
2156
|
case 'identifier':
|
|
2571
2157
|
return { supported: true, level: 'L1' }
|
|
2572
2158
|
|
|
2573
2159
|
case 'literal':
|
|
2574
2160
|
return { supported: true, level: 'L1' }
|
|
2575
2161
|
|
|
2576
|
-
case 'arrow
|
|
2577
|
-
|
|
2578
|
-
//
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
case 'higher-order': {
|
|
2583
|
-
// Check if predicate uses L1-L4 features
|
|
2584
|
-
const predSupport = checkSupport(expr.predicate)
|
|
2585
|
-
if (!predSupport.supported) {
|
|
2586
|
-
return {
|
|
2587
|
-
supported: false,
|
|
2588
|
-
level: 'L5_UNSUPPORTED',
|
|
2589
|
-
reason: `Higher-order method '${expr.method}()' with complex predicate. ${predSupport.reason || 'Simplify the predicate.'}`,
|
|
2590
|
-
}
|
|
2591
|
-
}
|
|
2592
|
-
// Nested higher-order INSIDE the predicate body (e.g.
|
|
2593
|
-
// `x => x.tags.filter(t => t.active).length > 0`) was refused
|
|
2594
|
-
// here historically because adapter emitters would produce
|
|
2595
|
-
// broken output for `[grep ...]->{length}` style chains. Note
|
|
2596
|
-
// this check is intentionally NOT extended to `expr.object`:
|
|
2597
|
-
// chained-receiver forms like `arr.filter(p).filter(q)` lower
|
|
2598
|
-
// correctly via the emitter's recursive `emit(object)` (which
|
|
2599
|
-
// wraps the inner result in another `grep`). The Copilot
|
|
2600
|
-
// review on #1444 asked us to either update this comment or
|
|
2601
|
-
// also reject chained receivers — preserving the chained
|
|
2602
|
-
// case is the right move because it already works.
|
|
2603
|
-
if (containsHigherOrder(expr.predicate)) {
|
|
2604
|
-
return {
|
|
2605
|
-
supported: false,
|
|
2606
|
-
level: 'L5_UNSUPPORTED',
|
|
2607
|
-
reason: `Nested higher-order methods inside a predicate body are not supported. Use @client directive.`,
|
|
2608
|
-
}
|
|
2609
|
-
}
|
|
2610
|
-
// The source array also has to be lowerable. Skipping this check
|
|
2611
|
-
// (matching the pre-#1443 behaviour) silently let `array-literal`
|
|
2612
|
-
// sources fall through to the adapter's `unsupported` arm and
|
|
2613
|
-
// through the regex pipeline — the recursion that #1421 / #1427
|
|
2614
|
-
// worked around.
|
|
2615
|
-
const objSupport = checkSupport(expr.object)
|
|
2616
|
-
if (!objSupport.supported) {
|
|
2617
|
-
return objSupport
|
|
2618
|
-
}
|
|
2619
|
-
return { supported: true, level: 'L5' }
|
|
2620
|
-
}
|
|
2162
|
+
case 'arrow':
|
|
2163
|
+
case 'regex':
|
|
2164
|
+
// Arrow functions / regex literals are only supported as the
|
|
2165
|
+
// argument of a recognised higher-order callback call (handled in
|
|
2166
|
+
// the `call` arm below); they're unsupported standalone.
|
|
2167
|
+
return { supported: false, reason: 'Standalone arrow functions / regex literals are not supported' }
|
|
2621
2168
|
|
|
2622
2169
|
case 'array-literal': {
|
|
2623
2170
|
// Array literal is lowerable iff every element is. Adapters that
|
|
@@ -2633,6 +2180,17 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
2633
2180
|
}
|
|
2634
2181
|
|
|
2635
2182
|
case 'array-method': {
|
|
2183
|
+
// A regex-pattern `.replace` is carried structurally (a `regex` first
|
|
2184
|
+
// arg) but is the deferred form (#1448) — no template language lowers it.
|
|
2185
|
+
// Refuse with the dedicated reason rather than the generic standalone-regex
|
|
2186
|
+
// message, preserving the diagnostic the parser used to emit directly.
|
|
2187
|
+
if (expr.method === 'replace' && expr.args[0]?.kind === 'regex') {
|
|
2188
|
+
return {
|
|
2189
|
+
supported: false,
|
|
2190
|
+
reason:
|
|
2191
|
+
'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 */',
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2636
2194
|
const objSupport = checkSupport(expr.object)
|
|
2637
2195
|
if (!objSupport.supported) return objSupport
|
|
2638
2196
|
for (const arg of expr.args) {
|
|
@@ -2644,6 +2202,32 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
2644
2202
|
|
|
2645
2203
|
|
|
2646
2204
|
case 'call': {
|
|
2205
|
+
// Higher-order callback methods (`.filter`/`.sort`/`.reduce`/… with an
|
|
2206
|
+
// arrow argument) lower via the runtime evaluator (#2018). Supported iff
|
|
2207
|
+
// the receiver and the callback BODY are supported. Recognised before the
|
|
2208
|
+
// `UNSUPPORTED_METHODS` gate so the eval-lowered shapes aren't refused
|
|
2209
|
+
// (a BARE method reference — `arr.filter` uncalled, no arrow arg — still
|
|
2210
|
+
// falls through to the gate). A nested callback inside the body refuses
|
|
2211
|
+
// at the adapter's `serializeParsedExpr` purity gate, not here.
|
|
2212
|
+
const cb = asCallbackMethodCall(expr)
|
|
2213
|
+
if (cb) {
|
|
2214
|
+
const objSupport = checkSupport(cb.object)
|
|
2215
|
+
if (!objSupport.supported) return objSupport
|
|
2216
|
+
const bodySupport = checkSupport(cb.arrow.body)
|
|
2217
|
+
if (!bodySupport.supported) {
|
|
2218
|
+
return {
|
|
2219
|
+
supported: false,
|
|
2220
|
+
level: 'L5_UNSUPPORTED',
|
|
2221
|
+
reason: `Higher-order method '.${cb.method}()' with complex callback. ${bodySupport.reason || 'Simplify the callback.'}`,
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
for (const rest of cb.args) {
|
|
2225
|
+
const restSupport = checkSupport(rest)
|
|
2226
|
+
if (!restSupport.supported) return restSupport
|
|
2227
|
+
}
|
|
2228
|
+
return { supported: true, level: 'L5' }
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2647
2231
|
// Check if callee is supported
|
|
2648
2232
|
const calleeSupport = checkSupport(expr.callee)
|
|
2649
2233
|
if (!calleeSupport.supported) {
|
|
@@ -2778,12 +2362,13 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
2778
2362
|
}
|
|
2779
2363
|
|
|
2780
2364
|
/**
|
|
2781
|
-
* Check if expression contains any higher-order method
|
|
2365
|
+
* Check if expression contains any higher-order callback method call
|
|
2366
|
+
* (`.filter`/`.sort`/`.reduce`/… with an arrow argument — see
|
|
2367
|
+
* {@link asCallbackMethodCall}) anywhere in the tree.
|
|
2782
2368
|
*/
|
|
2783
2369
|
export function containsHigherOrder(expr: ParsedExpr): boolean {
|
|
2370
|
+
if (asCallbackMethodCall(expr) !== null) return true
|
|
2784
2371
|
switch (expr.kind) {
|
|
2785
|
-
case 'higher-order':
|
|
2786
|
-
return true
|
|
2787
2372
|
case 'call':
|
|
2788
2373
|
return expr.args.some(containsHigherOrder) || containsHigherOrder(expr.callee)
|
|
2789
2374
|
case 'member':
|
|
@@ -2798,7 +2383,7 @@ export function containsHigherOrder(expr: ParsedExpr): boolean {
|
|
|
2798
2383
|
return containsHigherOrder(expr.left) || containsHigherOrder(expr.right)
|
|
2799
2384
|
case 'conditional':
|
|
2800
2385
|
return containsHigherOrder(expr.test) || containsHigherOrder(expr.consequent) || containsHigherOrder(expr.alternate)
|
|
2801
|
-
case 'arrow
|
|
2386
|
+
case 'arrow':
|
|
2802
2387
|
return containsHigherOrder(expr.body)
|
|
2803
2388
|
case 'array-literal':
|
|
2804
2389
|
return expr.elements.some(containsHigherOrder)
|
|
@@ -2847,6 +2432,29 @@ export function parseBlockBody(
|
|
|
2847
2432
|
return statements
|
|
2848
2433
|
}
|
|
2849
2434
|
|
|
2435
|
+
/**
|
|
2436
|
+
* Like {@link parseBlockBody} but tolerant: a statement `parseStatement` can't
|
|
2437
|
+
* represent is **skipped** rather than failing the whole block. Used to carry a
|
|
2438
|
+
* block-body memo's structure on the IR for adapters that only pattern-match a
|
|
2439
|
+
* recognised prefix of statements (e.g. a `const k = getter(); if (!k) return
|
|
2440
|
+
* CONST` guard) and ignore the rest — including a trailing client-directive
|
|
2441
|
+
* (`@client`) return that the strict parser would reject. Mirrors the tolerant
|
|
2442
|
+
* `continue`-on-unrecognised walks those adapters previously ran over a
|
|
2443
|
+
* re-parsed source string, so it never carries a *more* permissive result.
|
|
2444
|
+
*/
|
|
2445
|
+
export function parseBlockBodyTolerant(
|
|
2446
|
+
block: ts.Block,
|
|
2447
|
+
sourceFile: ts.SourceFile,
|
|
2448
|
+
getJS: (node: ts.Node) => string
|
|
2449
|
+
): ParsedStatement[] {
|
|
2450
|
+
const statements: ParsedStatement[] = []
|
|
2451
|
+
for (const stmt of block.statements) {
|
|
2452
|
+
const parsed = parseStatement(stmt, sourceFile, getJS)
|
|
2453
|
+
if (parsed !== null) statements.push(parsed)
|
|
2454
|
+
}
|
|
2455
|
+
return statements
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2850
2458
|
/**
|
|
2851
2459
|
* Parse a single statement into ParsedStatement.
|
|
2852
2460
|
*/
|
|
@@ -2876,8 +2484,17 @@ function parseStatement(
|
|
|
2876
2484
|
// return; (no value) -> return undefined, treat as return true
|
|
2877
2485
|
return { kind: 'return', value: { kind: 'literal', value: true, literalType: 'boolean' } }
|
|
2878
2486
|
}
|
|
2879
|
-
|
|
2880
|
-
|
|
2487
|
+
// A bare object-literal return (`return { a: 1 }`) re-parses as a *block*
|
|
2488
|
+
// statement if its braces lead the source, yielding `unsupported`. Unwrap
|
|
2489
|
+
// any parens, and when the returned expression is an object literal, wrap
|
|
2490
|
+
// the text in parens to force expression context so it parses as an
|
|
2491
|
+
// `object-literal` ParsedExpr (consumed by the Go object-memo lowering).
|
|
2492
|
+
let retExpr: ts.Expression = stmt.expression
|
|
2493
|
+
while (ts.isParenthesizedExpression(retExpr)) retExpr = retExpr.expression
|
|
2494
|
+
const valueText = getJS(retExpr)
|
|
2495
|
+
const value = parseExpression(
|
|
2496
|
+
ts.isObjectLiteralExpression(retExpr) ? `(${valueText})` : valueText,
|
|
2497
|
+
)
|
|
2881
2498
|
if (value.kind === 'unsupported') {
|
|
2882
2499
|
return null
|
|
2883
2500
|
}
|
|
@@ -2888,7 +2505,7 @@ function parseStatement(
|
|
|
2888
2505
|
if (ts.isIfStatement(stmt)) {
|
|
2889
2506
|
const conditionText = getJS(stmt.expression)
|
|
2890
2507
|
const condition = parseExpression(conditionText)
|
|
2891
|
-
if (condition.kind === 'unsupported') {
|
|
2508
|
+
if (condition.kind === 'unsupported' || condition.kind === 'object-literal') {
|
|
2892
2509
|
return null
|
|
2893
2510
|
}
|
|
2894
2511
|
|
|
@@ -2925,6 +2542,435 @@ function parseStatement(
|
|
|
2925
2542
|
return null
|
|
2926
2543
|
}
|
|
2927
2544
|
|
|
2545
|
+
// =============================================================================
|
|
2546
|
+
// Block → Expression Normalization (#2040)
|
|
2547
|
+
// =============================================================================
|
|
2548
|
+
|
|
2549
|
+
/**
|
|
2550
|
+
* The actionable refusal reason for a block body that is not purely-functionally
|
|
2551
|
+
* expressible. Carried on the `unsupported` ParsedExpr so adapters surface it as
|
|
2552
|
+
* the BF101 message. A loop that mutates a local to accumulate a value is a fold
|
|
2553
|
+
* (already expressible via `.reduce`); a loop that does anything else, a `break`,
|
|
2554
|
+
* a re-assignment, or a side-effecting/I-O call is genuinely imperative and has
|
|
2555
|
+
* no value-position lowering.
|
|
2556
|
+
*/
|
|
2557
|
+
export const IMPERATIVE_BLOCK_REASON =
|
|
2558
|
+
'Block body cannot be normalized to a value expression. Only pure ' +
|
|
2559
|
+
'`const` bindings, value-producing `if` / early `return`, and a final ' +
|
|
2560
|
+
'`return` are supported. Imperative shapes (raw `for` / `while` loops, ' +
|
|
2561
|
+
'`break`, local re-assignment, side-effecting or I/O calls) are not. ' +
|
|
2562
|
+
'Rewrite an accumulation loop as `.reduce(...)`, or move the imperative ' +
|
|
2563
|
+
'body to a `/* @client */` value so it runs natively on the client.'
|
|
2564
|
+
|
|
2565
|
+
/**
|
|
2566
|
+
* Whether a statement sequence always reaches a `return` on every control-flow
|
|
2567
|
+
* path (so anything textually after it is dead). A bare `return` terminates; an
|
|
2568
|
+
* `if` terminates only when it has an `else` and both branches terminate. Used
|
|
2569
|
+
* by {@link foldBlockToExpr} to decide whether the statements following an `if`
|
|
2570
|
+
* belong to the fall-through (else) path.
|
|
2571
|
+
*/
|
|
2572
|
+
function statementsTerminate(stmts: ParsedStatement[]): boolean {
|
|
2573
|
+
for (const s of stmts) {
|
|
2574
|
+
if (s.kind === 'return') return true
|
|
2575
|
+
if (
|
|
2576
|
+
s.kind === 'if' &&
|
|
2577
|
+
s.alternate !== undefined &&
|
|
2578
|
+
statementsTerminate(s.consequent) &&
|
|
2579
|
+
statementsTerminate(s.alternate)
|
|
2580
|
+
) {
|
|
2581
|
+
return true
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
return false
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
/**
|
|
2588
|
+
* Refusal reason when inlining a `const` binding would capture a free variable
|
|
2589
|
+
* of its initializer under a nested callback parameter of the same name. The
|
|
2590
|
+
* let-inline substitution is not a hygienic (alpha-renaming) substitution, so
|
|
2591
|
+
* rather than silently miscompile the callback we refuse and adapters surface
|
|
2592
|
+
* BF101.
|
|
2593
|
+
*/
|
|
2594
|
+
export const CAPTURE_BLOCK_REASON =
|
|
2595
|
+
'Block body cannot be normalized: inlining a `const` binding would capture ' +
|
|
2596
|
+
'one of its free variables under a nested callback parameter of the same ' +
|
|
2597
|
+
'name (e.g. `const x = a; … list.map(a => a + x)`). Rename the inner ' +
|
|
2598
|
+
'parameter, or move the body to a `/* @client */` value so it runs natively ' +
|
|
2599
|
+
'on the client.'
|
|
2600
|
+
|
|
2601
|
+
/**
|
|
2602
|
+
* Refusal reason when a `const` initializer may have side effects (it contains
|
|
2603
|
+
* a function/method call) and the binding is NOT used exactly once on a single
|
|
2604
|
+
* runtime path. Let-inline substitutes the initializer at each use site, which
|
|
2605
|
+
* would drop the effect (zero uses) or duplicate it (multiple uses / a use
|
|
2606
|
+
* inside a callback that runs per element) — exactly the side-effecting shape
|
|
2607
|
+
* the fold must refuse rather than miscompile.
|
|
2608
|
+
*/
|
|
2609
|
+
export const IMPURE_INLINE_BLOCK_REASON =
|
|
2610
|
+
'Block body cannot be normalized: a `const` whose initializer may have side ' +
|
|
2611
|
+
'effects (a function or method call) is not used exactly once on every path, ' +
|
|
2612
|
+
'so inlining it would drop the effect on some path or duplicate it on ' +
|
|
2613
|
+
'another. Bind a pure value, use the binding exactly once unconditionally, ' +
|
|
2614
|
+
'or move the body to a `/* @client */` value so it runs natively on the client.'
|
|
2615
|
+
|
|
2616
|
+
/**
|
|
2617
|
+
* Options for {@link foldBlockToExpr}.
|
|
2618
|
+
*/
|
|
2619
|
+
export interface FoldBlockOptions {
|
|
2620
|
+
/**
|
|
2621
|
+
* Names of zero-argument calls that are idempotent reads with no observable
|
|
2622
|
+
* side effect — chiefly reactive getters (signal / memo accessors), which
|
|
2623
|
+
* return the same value each time within a render. Inlining such a read at
|
|
2624
|
+
* multiple sites is evaluation-count-neutral, so the fold may treat
|
|
2625
|
+
* `getter()` as pure. The caller (e.g. `jsx-to-ir`) supplies the set from its
|
|
2626
|
+
* analyzer-collected signal/memo names; callers without that context (the
|
|
2627
|
+
* plain `convertNode` callback path) omit it and every call stays "possibly
|
|
2628
|
+
* impure". A non-empty arg list or a member-call (`a.b()`) is never treated as
|
|
2629
|
+
* pure by this set.
|
|
2630
|
+
*/
|
|
2631
|
+
pureCallNames?: ReadonlySet<string>
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/**
|
|
2635
|
+
* Whether an expression is provably free of side effects, so it is safe to
|
|
2636
|
+
* inline at any number of use sites. Conservative: a function / method call is
|
|
2637
|
+
* possibly impure, EXCEPT a zero-arg call to a name in `pureCallNames` (an
|
|
2638
|
+
* idempotent reactive getter read). Member access is treated as pure, matching
|
|
2639
|
+
* `substituteDestructuredFields` and the rest of the compiler's expression
|
|
2640
|
+
* handling.
|
|
2641
|
+
*/
|
|
2642
|
+
function isPureInit(e: ParsedExpr, pureCallNames?: ReadonlySet<string>): boolean {
|
|
2643
|
+
const pure = (x: ParsedExpr) => isPureInit(x, pureCallNames)
|
|
2644
|
+
switch (e.kind) {
|
|
2645
|
+
case 'identifier':
|
|
2646
|
+
case 'literal':
|
|
2647
|
+
case 'regex':
|
|
2648
|
+
return true
|
|
2649
|
+
case 'member':
|
|
2650
|
+
return pure(e.object)
|
|
2651
|
+
case 'index-access':
|
|
2652
|
+
return pure(e.object) && pure(e.index)
|
|
2653
|
+
case 'binary':
|
|
2654
|
+
case 'logical':
|
|
2655
|
+
return pure(e.left) && pure(e.right)
|
|
2656
|
+
case 'unary':
|
|
2657
|
+
return pure(e.argument)
|
|
2658
|
+
case 'conditional':
|
|
2659
|
+
return pure(e.test) && pure(e.consequent) && pure(e.alternate)
|
|
2660
|
+
case 'template-literal':
|
|
2661
|
+
return e.parts.every(p => p.type !== 'expression' || pure(p.expr))
|
|
2662
|
+
case 'array-literal':
|
|
2663
|
+
return e.elements.every(pure)
|
|
2664
|
+
case 'object-literal':
|
|
2665
|
+
return e.properties.every(p => pure(p.value))
|
|
2666
|
+
case 'call':
|
|
2667
|
+
// A zero-arg reactive getter read (`filter()`, `count()`) is idempotent;
|
|
2668
|
+
// any other call may be effectful or non-deterministic.
|
|
2669
|
+
return (
|
|
2670
|
+
e.callee.kind === 'identifier' &&
|
|
2671
|
+
e.args.length === 0 &&
|
|
2672
|
+
pureCallNames !== undefined &&
|
|
2673
|
+
pureCallNames.has(e.callee.name)
|
|
2674
|
+
)
|
|
2675
|
+
// A method call may be effectful; an arrow value can capture impurity;
|
|
2676
|
+
// `unsupported` is opaque. Treat all as possibly impure.
|
|
2677
|
+
case 'array-method':
|
|
2678
|
+
case 'arrow':
|
|
2679
|
+
case 'unsupported':
|
|
2680
|
+
return false
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
/**
|
|
2685
|
+
* The `{ min, max }` number of times `name` is evaluated on a single runtime
|
|
2686
|
+
* path through `expr` — the minimum-cost path and the maximum-cost path. Used to
|
|
2687
|
+
* decide whether inlining a possibly-impure init is evaluation-count-preserving:
|
|
2688
|
+
* sound only when it is evaluated **exactly once on every path** (`min === 1 &&
|
|
2689
|
+
* max === 1`), so the substituted call neither drops nor duplicates its effect.
|
|
2690
|
+
*
|
|
2691
|
+
* Path semantics:
|
|
2692
|
+
* - `conditional` evaluates the test then exactly one arm → test + the min/max
|
|
2693
|
+
* of the two arms (a binding used in only one arm has `min` 0: it is skipped
|
|
2694
|
+
* on the other path).
|
|
2695
|
+
* - `logical` (`&&` / `||` / `??`) evaluates the left, then the right only on
|
|
2696
|
+
* some paths (short-circuit) → the right contributes to `max` but not `min`.
|
|
2697
|
+
* - a nested `arrow` body may run any number of times (a callback invoked per
|
|
2698
|
+
* element / comparison, or never) → a use inside has `min` 0 and `max`
|
|
2699
|
+
* `Infinity`, forcing an impure binding referenced from a callback to be
|
|
2700
|
+
* refused.
|
|
2701
|
+
*/
|
|
2702
|
+
function usesPerPath(name: string, expr: ParsedExpr): { min: number; max: number } {
|
|
2703
|
+
const add = (a: { min: number; max: number }, b: { min: number; max: number }) => ({
|
|
2704
|
+
min: a.min + b.min,
|
|
2705
|
+
max: a.max + b.max,
|
|
2706
|
+
})
|
|
2707
|
+
const sum = (xs: ParsedExpr[]) => xs.reduce((acc, x) => add(acc, walk(x)), { min: 0, max: 0 })
|
|
2708
|
+
const walk = (e: ParsedExpr): { min: number; max: number } => {
|
|
2709
|
+
switch (e.kind) {
|
|
2710
|
+
case 'identifier':
|
|
2711
|
+
return e.name === name ? { min: 1, max: 1 } : { min: 0, max: 0 }
|
|
2712
|
+
case 'literal':
|
|
2713
|
+
case 'regex':
|
|
2714
|
+
case 'unsupported':
|
|
2715
|
+
return { min: 0, max: 0 }
|
|
2716
|
+
case 'member':
|
|
2717
|
+
return walk(e.object)
|
|
2718
|
+
case 'index-access':
|
|
2719
|
+
return add(walk(e.object), walk(e.index))
|
|
2720
|
+
case 'binary':
|
|
2721
|
+
return add(walk(e.left), walk(e.right))
|
|
2722
|
+
case 'logical': {
|
|
2723
|
+
// The right operand is only evaluated on some paths (short-circuit), so
|
|
2724
|
+
// it contributes to the max but never to the guaranteed min.
|
|
2725
|
+
const l = walk(e.left)
|
|
2726
|
+
const r = walk(e.right)
|
|
2727
|
+
return { min: l.min, max: l.max + r.max }
|
|
2728
|
+
}
|
|
2729
|
+
case 'unary':
|
|
2730
|
+
return walk(e.argument)
|
|
2731
|
+
case 'conditional': {
|
|
2732
|
+
const t = walk(e.test)
|
|
2733
|
+
const c = walk(e.consequent)
|
|
2734
|
+
const a = walk(e.alternate)
|
|
2735
|
+
return { min: t.min + Math.min(c.min, a.min), max: t.max + Math.max(c.max, a.max) }
|
|
2736
|
+
}
|
|
2737
|
+
case 'template-literal':
|
|
2738
|
+
return sum(e.parts.flatMap(p => (p.type === 'expression' ? [p.expr] : [])))
|
|
2739
|
+
case 'call':
|
|
2740
|
+
return add(walk(e.callee), sum(e.args))
|
|
2741
|
+
case 'array-literal':
|
|
2742
|
+
return sum(e.elements)
|
|
2743
|
+
case 'array-method':
|
|
2744
|
+
return add(walk(e.object), e.method === 'flat' ? { min: 0, max: 0 } : sum(e.args))
|
|
2745
|
+
case 'object-literal':
|
|
2746
|
+
return sum(e.properties.map(p => p.value))
|
|
2747
|
+
case 'arrow':
|
|
2748
|
+
// A callback body may run any number of times (per element, or never).
|
|
2749
|
+
return walk(e.body).max > 0 ? { min: 0, max: Number.POSITIVE_INFINITY } : { min: 0, max: 0 }
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return walk(expr)
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
/**
|
|
2756
|
+
* Inline `name → value` everywhere it appears free in `expr` (the let-inline
|
|
2757
|
+
* step). Returns `null` if the substitution would capture a free variable of
|
|
2758
|
+
* `value` under a nested callback parameter of the same name — that shape is
|
|
2759
|
+
* unsound to inline non-hygienically, so the caller refuses with
|
|
2760
|
+
* {@link CAPTURE_BLOCK_REASON}. A nested arrow parameter that shadows `name`
|
|
2761
|
+
* leaves that inner reference untouched (it is the parameter, not the binding).
|
|
2762
|
+
* Mirrors the structural walk of `substituteDestructuredFields`; every
|
|
2763
|
+
* `ParsedExpr` kind is handled so a new kind surfaces as a compile error here.
|
|
2764
|
+
*/
|
|
2765
|
+
function inlineBinding(
|
|
2766
|
+
expr: ParsedExpr,
|
|
2767
|
+
name: string,
|
|
2768
|
+
value: ParsedExpr,
|
|
2769
|
+
): ParsedExpr | null {
|
|
2770
|
+
// Free variables of `value` that an enclosing callback parameter could capture.
|
|
2771
|
+
const valueFree = new Set<string>()
|
|
2772
|
+
collectIdentifiers(value, valueFree)
|
|
2773
|
+
let captured = false
|
|
2774
|
+
|
|
2775
|
+
const walk = (e: ParsedExpr, enclosing: ReadonlySet<string>): ParsedExpr => {
|
|
2776
|
+
switch (e.kind) {
|
|
2777
|
+
case 'identifier': {
|
|
2778
|
+
if (e.name !== name) return e
|
|
2779
|
+
// Shadowed by an enclosing callback param → this is the parameter, not
|
|
2780
|
+
// the binding; leave it.
|
|
2781
|
+
if (enclosing.has(e.name)) return e
|
|
2782
|
+
// Inlining here: does any enclosing param capture a free var of `value`?
|
|
2783
|
+
for (const p of enclosing) {
|
|
2784
|
+
if (valueFree.has(p)) {
|
|
2785
|
+
captured = true
|
|
2786
|
+
return e
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
return value
|
|
2790
|
+
}
|
|
2791
|
+
case 'call':
|
|
2792
|
+
return { kind: 'call', callee: walk(e.callee, enclosing), args: e.args.map(a => walk(a, enclosing)) }
|
|
2793
|
+
case 'member':
|
|
2794
|
+
return { kind: 'member', object: walk(e.object, enclosing), property: e.property, computed: e.computed }
|
|
2795
|
+
case 'index-access':
|
|
2796
|
+
return { kind: 'index-access', object: walk(e.object, enclosing), index: walk(e.index, enclosing) }
|
|
2797
|
+
case 'binary':
|
|
2798
|
+
return { kind: 'binary', op: e.op, left: walk(e.left, enclosing), right: walk(e.right, enclosing) }
|
|
2799
|
+
case 'logical':
|
|
2800
|
+
return { kind: 'logical', op: e.op, left: walk(e.left, enclosing), right: walk(e.right, enclosing) }
|
|
2801
|
+
case 'unary':
|
|
2802
|
+
return { kind: 'unary', op: e.op, argument: walk(e.argument, enclosing) }
|
|
2803
|
+
case 'conditional':
|
|
2804
|
+
return { kind: 'conditional', test: walk(e.test, enclosing), consequent: walk(e.consequent, enclosing), alternate: walk(e.alternate, enclosing) }
|
|
2805
|
+
case 'template-literal':
|
|
2806
|
+
return {
|
|
2807
|
+
kind: 'template-literal',
|
|
2808
|
+
parts: e.parts.map(p =>
|
|
2809
|
+
p.type === 'expression' ? { type: 'expression', expr: walk(p.expr, enclosing) } : p,
|
|
2810
|
+
),
|
|
2811
|
+
}
|
|
2812
|
+
case 'arrow': {
|
|
2813
|
+
const innerEnclosing = e.params.length === 0 ? enclosing : new Set([...enclosing, ...e.params])
|
|
2814
|
+
return { kind: 'arrow', params: e.params, body: walk(e.body, innerEnclosing) }
|
|
2815
|
+
}
|
|
2816
|
+
case 'array-literal':
|
|
2817
|
+
return { kind: 'array-literal', elements: e.elements.map(el => walk(el, enclosing)) }
|
|
2818
|
+
case 'array-method':
|
|
2819
|
+
if (e.method === 'flat') {
|
|
2820
|
+
return { kind: 'array-method', method: 'flat', object: walk(e.object, enclosing), args: [], flatDepth: e.flatDepth }
|
|
2821
|
+
}
|
|
2822
|
+
return { kind: 'array-method', method: e.method, object: walk(e.object, enclosing), args: e.args.map(a => walk(a, enclosing)) }
|
|
2823
|
+
case 'object-literal':
|
|
2824
|
+
return {
|
|
2825
|
+
kind: 'object-literal',
|
|
2826
|
+
properties: e.properties.map(p => ({ ...p, value: walk(p.value, enclosing) })),
|
|
2827
|
+
raw: e.raw,
|
|
2828
|
+
}
|
|
2829
|
+
case 'literal':
|
|
2830
|
+
case 'regex':
|
|
2831
|
+
case 'unsupported':
|
|
2832
|
+
return e
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
const result = walk(expr, new Set())
|
|
2837
|
+
return captured ? null : result
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/**
|
|
2841
|
+
* Fold a value-producing block body — a {@link ParsedStatement} sequence of
|
|
2842
|
+
* `const` bindings, value-producing `if` / early `return`, and a terminal
|
|
2843
|
+
* `return` — into a single {@link ParsedExpr}, so block-bodied memos / derived /
|
|
2844
|
+
* callbacks flow through the same expression surface as expression-bodied ones
|
|
2845
|
+
* (#2040, carved from #2018 stage 5). This generalizes the per-idiom block-memo
|
|
2846
|
+
* recognizers (#1897 / #1945 / #2015) the same way the evaluator replaced the
|
|
2847
|
+
* `bf_sort` / `bf_reduce` catalogue: one normalization, no growing pattern list.
|
|
2848
|
+
*
|
|
2849
|
+
* Transformations:
|
|
2850
|
+
* - `const x = <init>; …` → inline `x`'s init into the rest (let-inline).
|
|
2851
|
+
* - `if (c) <then> [else <else>] …` → `c ? fold(then-path) : fold(else-path)`,
|
|
2852
|
+
* where a branch that does not itself terminate continues into the
|
|
2853
|
+
* statements following the `if` (the early-return idiom).
|
|
2854
|
+
* - `return <v>` → `<v>`.
|
|
2855
|
+
*
|
|
2856
|
+
* The rest is folded first, leaving each binding as a free identifier, so its
|
|
2857
|
+
* use count can be measured before inlining. Inlining is refused (→ `ok: false`)
|
|
2858
|
+
* when it would be unsound:
|
|
2859
|
+
* - a possibly-impure init (one containing a call) used zero or more than once
|
|
2860
|
+
* on a path would drop or duplicate the side effect (a pure init is always
|
|
2861
|
+
* safe to inline any number of times);
|
|
2862
|
+
* - a substitution would capture a free variable of the init under a nested
|
|
2863
|
+
* callback parameter of the same name (substitution is not hygienic).
|
|
2864
|
+
*
|
|
2865
|
+
* Returns `{ ok: false }` for a sequence that cannot produce a value on some
|
|
2866
|
+
* path (falls through with no `return`) — the genuinely-imperative residue that
|
|
2867
|
+
* {@link IMPERATIVE_BLOCK_REASON} describes. The input is assumed to be the
|
|
2868
|
+
* STRICT parse ({@link parseBlockBody}, not the tolerant variant): every source
|
|
2869
|
+
* statement is represented, so a `false` here reflects the real shape rather
|
|
2870
|
+
* than a silently-dropped statement.
|
|
2871
|
+
*/
|
|
2872
|
+
export function foldBlockToExpr(
|
|
2873
|
+
stmts: ParsedStatement[],
|
|
2874
|
+
opts?: FoldBlockOptions,
|
|
2875
|
+
): { ok: true; expr: ParsedExpr } | { ok: false; reason: string } {
|
|
2876
|
+
if (stmts.length === 0) {
|
|
2877
|
+
return { ok: false, reason: IMPERATIVE_BLOCK_REASON }
|
|
2878
|
+
}
|
|
2879
|
+
const [head, ...rest] = stmts
|
|
2880
|
+
switch (head.kind) {
|
|
2881
|
+
case 'var-decl': {
|
|
2882
|
+
// Fold the remaining statements first, leaving `head.name` free so its use
|
|
2883
|
+
// count can drive the soundness check. Any earlier-binding references in
|
|
2884
|
+
// the rest are inlined by the enclosing `var-decl` frames; references to
|
|
2885
|
+
// `head.name` inside `head.init` cannot occur (a `const` can't read itself).
|
|
2886
|
+
const restFold = foldBlockToExpr(rest, opts)
|
|
2887
|
+
if (!restFold.ok) return restFold
|
|
2888
|
+
const uses = usesPerPath(head.name, restFold.expr)
|
|
2889
|
+
// A possibly-impure init is only safe to inline when it is evaluated
|
|
2890
|
+
// exactly once on EVERY path — same as the original block, which runs the
|
|
2891
|
+
// `const` initializer unconditionally once. `min !== 1` catches an effect
|
|
2892
|
+
// dropped on some path (unused, or used in only one ternary arm / a
|
|
2893
|
+
// short-circuited operand / a callback); `max !== 1` catches duplication.
|
|
2894
|
+
// A pure init is safe at any count (drop / duplicate is unobservable);
|
|
2895
|
+
// idempotent reactive getter reads in `pureCallNames` count as pure.
|
|
2896
|
+
if (!isPureInit(head.init, opts?.pureCallNames) && !(uses.min === 1 && uses.max === 1)) {
|
|
2897
|
+
return { ok: false, reason: IMPURE_INLINE_BLOCK_REASON }
|
|
2898
|
+
}
|
|
2899
|
+
const inlined = inlineBinding(restFold.expr, head.name, head.init)
|
|
2900
|
+
if (inlined === null) {
|
|
2901
|
+
return { ok: false, reason: CAPTURE_BLOCK_REASON }
|
|
2902
|
+
}
|
|
2903
|
+
return { ok: true, expr: inlined }
|
|
2904
|
+
}
|
|
2905
|
+
case 'return':
|
|
2906
|
+
return { ok: true, expr: head.value }
|
|
2907
|
+
case 'if': {
|
|
2908
|
+
// A branch that doesn't return falls through to the statements after the
|
|
2909
|
+
// `if` (early-return idiom). A branch that returns makes `rest` dead for
|
|
2910
|
+
// that path, so it is not appended. `rest` is intentionally duplicated
|
|
2911
|
+
// into both fall-through paths; because each path is a separate ternary
|
|
2912
|
+
// arm, a binding used once per arm is still evaluated at most once per
|
|
2913
|
+
// runtime path.
|
|
2914
|
+
const thenPath = statementsTerminate(head.consequent)
|
|
2915
|
+
? head.consequent
|
|
2916
|
+
: [...head.consequent, ...rest]
|
|
2917
|
+
const elseBase = head.alternate ?? []
|
|
2918
|
+
const elsePath = statementsTerminate(elseBase)
|
|
2919
|
+
? elseBase
|
|
2920
|
+
: [...elseBase, ...rest]
|
|
2921
|
+
const consequent = foldBlockToExpr(thenPath, opts)
|
|
2922
|
+
if (!consequent.ok) return consequent
|
|
2923
|
+
const alternate = foldBlockToExpr(elsePath, opts)
|
|
2924
|
+
if (!alternate.ok) return alternate
|
|
2925
|
+
return {
|
|
2926
|
+
ok: true,
|
|
2927
|
+
expr: {
|
|
2928
|
+
kind: 'conditional',
|
|
2929
|
+
test: head.condition,
|
|
2930
|
+
consequent: consequent.expr,
|
|
2931
|
+
alternate: alternate.expr,
|
|
2932
|
+
},
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
/**
|
|
2939
|
+
* Rewrite a ternary whose arms are used in BOOLEAN context — e.g. the result of
|
|
2940
|
+
* folding a block-bodied filter predicate (`if (c) return A; return B` →
|
|
2941
|
+
* `c ? A : B`) — into an equivalent `&&` / `||` expression, so it flows through
|
|
2942
|
+
* the ordinary boolean-expression lowering instead of needing a dedicated
|
|
2943
|
+
* block-condition renderer per adapter (#2040). Boolean-literal arms collapse:
|
|
2944
|
+
*
|
|
2945
|
+
* c ? true : false → c
|
|
2946
|
+
* c ? true : f → c || f
|
|
2947
|
+
* c ? t : false → c && t
|
|
2948
|
+
* c ? false : f → !c && f
|
|
2949
|
+
* c ? t : true → !c || t
|
|
2950
|
+
* c ? t : f → (c && t) || (!c && f)
|
|
2951
|
+
*
|
|
2952
|
+
* Arms are flattened recursively (an `else if` chain is a nested ternary); the
|
|
2953
|
+
* test is left as-is. Only valid where the consumer interprets the value as a
|
|
2954
|
+
* boolean (a filter predicate). Non-conditional input is returned unchanged.
|
|
2955
|
+
*/
|
|
2956
|
+
export function predicateTernaryToLogical(expr: ParsedExpr): ParsedExpr {
|
|
2957
|
+
if (expr.kind !== 'conditional') return expr
|
|
2958
|
+
const cond = expr.test
|
|
2959
|
+
const t = predicateTernaryToLogical(expr.consequent)
|
|
2960
|
+
const f = predicateTernaryToLogical(expr.alternate)
|
|
2961
|
+
const isTrue = (x: ParsedExpr) => x.kind === 'literal' && x.literalType === 'boolean' && x.value === true
|
|
2962
|
+
const isFalse = (x: ParsedExpr) => x.kind === 'literal' && x.literalType === 'boolean' && x.value === false
|
|
2963
|
+
const not = (x: ParsedExpr): ParsedExpr => ({ kind: 'unary', op: '!', argument: x })
|
|
2964
|
+
const and = (a: ParsedExpr, b: ParsedExpr): ParsedExpr => ({ kind: 'logical', op: '&&', left: a, right: b })
|
|
2965
|
+
const or = (a: ParsedExpr, b: ParsedExpr): ParsedExpr => ({ kind: 'logical', op: '||', left: a, right: b })
|
|
2966
|
+
if (isTrue(t) && isFalse(f)) return cond
|
|
2967
|
+
if (isTrue(t)) return or(cond, f)
|
|
2968
|
+
if (isFalse(f)) return and(cond, t)
|
|
2969
|
+
if (isFalse(t)) return and(not(cond), f)
|
|
2970
|
+
if (isTrue(f)) return or(not(cond), t)
|
|
2971
|
+
return or(and(cond, t), and(not(cond), f))
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2928
2974
|
/**
|
|
2929
2975
|
* Parse an if branch (then or else) into ParsedStatement array.
|
|
2930
2976
|
*/
|
|
@@ -2975,28 +3021,17 @@ export function exprToString(expr: ParsedExpr): string {
|
|
|
2975
3021
|
return '`' + expr.parts.map(p =>
|
|
2976
3022
|
p.type === 'string' ? p.value : `\${${exprToString(p.expr)}}`
|
|
2977
3023
|
).join('') + '`'
|
|
2978
|
-
case 'arrow
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
3024
|
+
case 'arrow': {
|
|
3025
|
+
// Single-param arrows round-trip without parens (`x => …`); multi-param
|
|
3026
|
+
// need them (`(a, b) => …`).
|
|
3027
|
+
const params = expr.params.length === 1 ? expr.params[0] : `(${expr.params.join(', ')})`
|
|
3028
|
+
return `${params} => ${exprToString(expr.body)}`
|
|
3029
|
+
}
|
|
3030
|
+
case 'regex':
|
|
3031
|
+
return expr.raw
|
|
2982
3032
|
case 'array-literal':
|
|
2983
3033
|
return `[${expr.elements.map(exprToString).join(', ')}]`
|
|
2984
3034
|
case 'array-method':
|
|
2985
|
-
if (expr.method === 'sort' || expr.method === 'toSorted') {
|
|
2986
|
-
// Reconstruct against the user's actual param names — the
|
|
2987
|
-
// comparator body in `raw` references them directly, so
|
|
2988
|
-
// hardcoding `(a,b)` would produce un-re-parseable output
|
|
2989
|
-
// for any user who wrote e.g. `(lhs, rhs) => lhs - rhs`.
|
|
2990
|
-
const { paramA, paramB, raw } = expr.comparator
|
|
2991
|
-
return `${exprToString(expr.object)}.${expr.method}((${paramA},${paramB}) => ${raw})`
|
|
2992
|
-
}
|
|
2993
|
-
if (expr.method === 'reduce' || expr.method === 'reduceRight') {
|
|
2994
|
-
const { paramAcc, paramItem, raw, type, init } = expr.reduceOp
|
|
2995
|
-
// `init` is the decoded value: re-quote a string seed, re-emit a
|
|
2996
|
-
// numeric seed as-is (it's already a valid number literal).
|
|
2997
|
-
const initSrc = type === 'string' ? JSON.stringify(init) : init
|
|
2998
|
-
return `${exprToString(expr.object)}.${expr.method}((${paramAcc},${paramItem}) => ${raw}, ${initSrc})`
|
|
2999
|
-
}
|
|
3000
3035
|
if (expr.method === 'flat') {
|
|
3001
3036
|
// Preserve the normalised depth so diagnostics don't misleadingly
|
|
3002
3037
|
// print `.flat()` for a `.flat(2)` / `.flat(Infinity)` source.
|
|
@@ -3004,12 +3039,11 @@ export function exprToString(expr: ParsedExpr): string {
|
|
|
3004
3039
|
const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
|
|
3005
3040
|
return `${exprToString(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
|
|
3006
3041
|
}
|
|
3007
|
-
if (expr.method === 'flatMap') {
|
|
3008
|
-
const { param, raw } = expr.flatMapOp
|
|
3009
|
-
return `${exprToString(expr.object)}.flatMap(${param} => ${raw})`
|
|
3010
|
-
}
|
|
3011
3042
|
return `${exprToString(expr.object)}.${expr.method}(${expr.args.map(exprToString).join(', ')})`
|
|
3012
3043
|
case 'unsupported':
|
|
3044
|
+
// `raw` holds the original expression string (same value the old
|
|
3045
|
+
// `unsupported` carried), so the round-trip stays byte-identical.
|
|
3046
|
+
case 'object-literal':
|
|
3013
3047
|
return `[UNSUPPORTED: ${expr.raw}]`
|
|
3014
3048
|
}
|
|
3015
3049
|
}
|
|
@@ -3058,30 +3092,19 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
|
|
|
3058
3092
|
return '`' + expr.parts.map(p =>
|
|
3059
3093
|
p.type === 'string' ? p.value : `\${${stringifyParsedExpr(p.expr)}}`
|
|
3060
3094
|
).join('') + '`'
|
|
3061
|
-
case 'arrow
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3095
|
+
case 'arrow': {
|
|
3096
|
+
// Round-trip to valid JS for the CSR / Hono path and downstream
|
|
3097
|
+
// re-parsers. A higher-order callback (`.sort`/`.filter`/…) reaches here
|
|
3098
|
+
// as the arrow argument of a generic `call`. Single-param arrows round-
|
|
3099
|
+
// trip without parens (`x => …`); multi-param need them (`(a, b) => …`).
|
|
3100
|
+
const params = expr.params.length === 1 ? expr.params[0] : `(${expr.params.join(', ')})`
|
|
3101
|
+
return `${params} => ${stringifyParsedExpr(expr.body)}`
|
|
3102
|
+
}
|
|
3103
|
+
case 'regex':
|
|
3104
|
+
return expr.raw
|
|
3065
3105
|
case 'array-literal':
|
|
3066
3106
|
return `[${expr.elements.map(stringifyParsedExpr).join(', ')}]`
|
|
3067
3107
|
case 'array-method':
|
|
3068
|
-
if (expr.method === 'sort' || expr.method === 'toSorted') {
|
|
3069
|
-
// Round-trip the original param names so downstream
|
|
3070
|
-
// re-parsers (templatePrimitive substitution etc.) see
|
|
3071
|
-
// valid JS — `raw` references the user's names verbatim.
|
|
3072
|
-
const { paramA, paramB, raw } = expr.comparator
|
|
3073
|
-
return `${stringifyParsedExpr(expr.object)}.${expr.method}((${paramA},${paramB}) => ${raw})`
|
|
3074
|
-
}
|
|
3075
|
-
if (expr.method === 'reduce' || expr.method === 'reduceRight') {
|
|
3076
|
-
// Round-trip the user's param names + init so downstream
|
|
3077
|
-
// re-parsers (the CSR / Hono JS path, templatePrimitive
|
|
3078
|
-
// substitution) see valid JS — `raw` references the names
|
|
3079
|
-
// verbatim. `init` is the decoded value: re-quote a string
|
|
3080
|
-
// seed via JSON.stringify, re-emit a numeric seed as-is.
|
|
3081
|
-
const { paramAcc, paramItem, raw, type, init } = expr.reduceOp
|
|
3082
|
-
const initSrc = type === 'string' ? JSON.stringify(init) : init
|
|
3083
|
-
return `${stringifyParsedExpr(expr.object)}.${expr.method}((${paramAcc},${paramItem}) => ${raw}, ${initSrc})`
|
|
3084
|
-
}
|
|
3085
3108
|
if (expr.method === 'flat') {
|
|
3086
3109
|
// Round-trip the normalised depth back to JS for the CSR / Hono
|
|
3087
3110
|
// path: `'infinity'` → `Infinity`, `1` is left implicit (`.flat()`).
|
|
@@ -3089,18 +3112,251 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
|
|
|
3089
3112
|
const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
|
|
3090
3113
|
return `${stringifyParsedExpr(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
|
|
3091
3114
|
}
|
|
3092
|
-
if (expr.method === 'flatMap') {
|
|
3093
|
-
// Round-trip the user's callback param + body so the CSR / Hono
|
|
3094
|
-
// path re-parses valid JS (`raw` references the param verbatim).
|
|
3095
|
-
const { param, raw } = expr.flatMapOp
|
|
3096
|
-
return `${stringifyParsedExpr(expr.object)}.flatMap(${param} => ${raw})`
|
|
3097
|
-
}
|
|
3098
3115
|
return `${stringifyParsedExpr(expr.object)}.${expr.method}(${expr.args.map(stringifyParsedExpr).join(', ')})`
|
|
3099
3116
|
case 'unsupported':
|
|
3117
|
+
// `raw` is the original expression string, so re-stringification is
|
|
3118
|
+
// byte-identical to the pre-`object-literal` behaviour (Roadmap A-1).
|
|
3119
|
+
case 'object-literal':
|
|
3100
3120
|
return expr.raw
|
|
3101
3121
|
}
|
|
3102
3122
|
}
|
|
3103
3123
|
|
|
3124
|
+
/**
|
|
3125
|
+
* Serialize a pure-expression `ParsedExpr` (a higher-order callback body) into
|
|
3126
|
+
* the minimal JSON the runtime evaluator consumes — the format pinned by the
|
|
3127
|
+
* `eval-vectors` golden cases and read by Go `eval.go` `EvalNode` / Perl
|
|
3128
|
+
* `Evaluator.pm` `evaluate`. Only the evaluator-recognized fields are emitted
|
|
3129
|
+
* per kind (a literal carries just `value`; `literalType` / `raw` are dropped —
|
|
3130
|
+
* the evaluator never reads them; `member.computed` is kept when set so a
|
|
3131
|
+
* computed member stays distinguishable), keeping the embedded body blob small
|
|
3132
|
+
* and stable.
|
|
3133
|
+
*
|
|
3134
|
+
* Returns `null` when the tree contains a shape outside the evaluator's surface
|
|
3135
|
+
* — a folded `higher-order` / `array-method`, an `arrow-fn`, an `unsupported`
|
|
3136
|
+
* node, an operator the evaluator doesn't implement, or a `call` whose callee
|
|
3137
|
+
* isn't an allowlisted builtin (`Math.*` / `String` / `Number` / `Boolean`) — so
|
|
3138
|
+
* the caller refuses the body (BF101 / `@client`) instead of emitting a blob the
|
|
3139
|
+
* evaluator would read as nil. The evaluator's support criterion is
|
|
3140
|
+
* purely-functional expressibility; this is its compile-time gate. (#2018)
|
|
3141
|
+
*/
|
|
3142
|
+
export function serializeParsedExpr(expr: ParsedExpr): string | null {
|
|
3143
|
+
const node = toEvalNode(expr)
|
|
3144
|
+
return node === null ? null : JSON.stringify(node)
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
/**
|
|
3148
|
+
* The free variables a higher-order callback body references — every bare
|
|
3149
|
+
* identifier in a value position, minus the callback's own `params`. The
|
|
3150
|
+
* adapter materializes each into the evaluator's `base_env` (mapping the JS name
|
|
3151
|
+
* to its SSR value). Walks exactly the value positions {@link serializeParsedExpr}
|
|
3152
|
+
* serializes (so it sees object-literal *values* and template-expression parts,
|
|
3153
|
+
* and skips member property names / object keys, which are not references).
|
|
3154
|
+
* Returns a sorted, de-duplicated list for stable emit. (#2018)
|
|
3155
|
+
*/
|
|
3156
|
+
export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): string[] {
|
|
3157
|
+
const found = new Set<string>()
|
|
3158
|
+
const visit = (e: ParsedExpr): void => {
|
|
3159
|
+
switch (e.kind) {
|
|
3160
|
+
case 'identifier':
|
|
3161
|
+
if (!params.has(e.name)) found.add(e.name)
|
|
3162
|
+
return
|
|
3163
|
+
case 'binary':
|
|
3164
|
+
case 'logical':
|
|
3165
|
+
visit(e.left)
|
|
3166
|
+
visit(e.right)
|
|
3167
|
+
return
|
|
3168
|
+
case 'unary':
|
|
3169
|
+
visit(e.argument)
|
|
3170
|
+
return
|
|
3171
|
+
case 'conditional':
|
|
3172
|
+
visit(e.test)
|
|
3173
|
+
visit(e.consequent)
|
|
3174
|
+
visit(e.alternate)
|
|
3175
|
+
return
|
|
3176
|
+
case 'member':
|
|
3177
|
+
visit(e.object)
|
|
3178
|
+
return
|
|
3179
|
+
case 'index-access':
|
|
3180
|
+
visit(e.object)
|
|
3181
|
+
visit(e.index)
|
|
3182
|
+
return
|
|
3183
|
+
case 'call':
|
|
3184
|
+
// A builtin callee (`String`/`Number`/`Boolean`, or `Math.<fn>`) is
|
|
3185
|
+
// resolved syntactically by the evaluator — its identifier is NOT a
|
|
3186
|
+
// captured free var. Visiting it would add `Math` / `String` to the
|
|
3187
|
+
// env, making the adapter emit an undefined `$Math` / `.Math` base_env
|
|
3188
|
+
// entry (Copilot review #2031). Skip the callee identifier in that
|
|
3189
|
+
// case; the arguments are still real references and are visited.
|
|
3190
|
+
if (evalBuiltinCalleeName(e.callee) === null) visit(e.callee)
|
|
3191
|
+
e.args.forEach(visit)
|
|
3192
|
+
return
|
|
3193
|
+
case 'template-literal':
|
|
3194
|
+
for (const p of e.parts) if (p.type === 'expression') visit(p.expr)
|
|
3195
|
+
return
|
|
3196
|
+
case 'array-literal':
|
|
3197
|
+
e.elements.forEach(visit)
|
|
3198
|
+
return
|
|
3199
|
+
case 'object-literal':
|
|
3200
|
+
// Object *values* are references; keys are not. (Shorthand `{ x }`
|
|
3201
|
+
// carries the ref on its `value` identifier, which is visited here.)
|
|
3202
|
+
for (const p of e.properties) visit(p.value)
|
|
3203
|
+
return
|
|
3204
|
+
// Non-serializable kinds don't occur in a serializable body
|
|
3205
|
+
// (serializeParsedExpr returns null for them); nothing to collect.
|
|
3206
|
+
case 'literal':
|
|
3207
|
+
case 'array-method':
|
|
3208
|
+
case 'arrow':
|
|
3209
|
+
case 'regex':
|
|
3210
|
+
case 'unsupported':
|
|
3211
|
+
return
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
visit(body)
|
|
3215
|
+
return [...found].sort()
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
// Operators the evaluator implements (Go `eval.go` evalBinary / evalUnary, Perl
|
|
3219
|
+
// `Evaluator.pm` _binary / _unary). An op outside these sets — loose `==`,
|
|
3220
|
+
// `instanceof`, `**`, bitwise/shift, or the parser's `'unknown'` sentinel — is
|
|
3221
|
+
// refused so the body falls back to BF101 rather than serializing an op the
|
|
3222
|
+
// evaluator would silently mis-handle.
|
|
3223
|
+
const EVAL_BINARY_OPS: ReadonlySet<string> = new Set([
|
|
3224
|
+
'+', '-', '*', '/', '%', '<', '<=', '>', '>=', '===', '!==',
|
|
3225
|
+
])
|
|
3226
|
+
const EVAL_UNARY_OPS: ReadonlySet<string> = new Set(['!', '-', '+'])
|
|
3227
|
+
|
|
3228
|
+
// The only call shapes the evaluator executes (Go `eval.go` evalBuiltinName /
|
|
3229
|
+
// evalCallBuiltin, Perl `Evaluator.pm` _call_builtin): a bare `String` / `Number`
|
|
3230
|
+
// / `Boolean`, or a NON-computed `Math.<fn>` for a fixed `<fn>` set. Any other
|
|
3231
|
+
// callee — a bare function (`foo(x)`), a method (`x.bar(...)`), or a *computed*
|
|
3232
|
+
// builtin (`Math['max']`, which the evaluator rejects) — evaluates to nil at
|
|
3233
|
+
// runtime, so the gate refuses it at compile time instead (BF101 / `@client`).
|
|
3234
|
+
const EVAL_BUILTIN_IDENTS: ReadonlySet<string> = new Set(['String', 'Number', 'Boolean'])
|
|
3235
|
+
const EVAL_MATH_METHODS: ReadonlySet<string> = new Set([
|
|
3236
|
+
'max', 'min', 'abs', 'floor', 'ceil', 'round',
|
|
3237
|
+
])
|
|
3238
|
+
|
|
3239
|
+
/** The allowlisted builtin name a call callee resolves to (`Math.max` / `String`), or null. */
|
|
3240
|
+
function evalBuiltinCalleeName(callee: ParsedExpr): string | null {
|
|
3241
|
+
if (callee.kind === 'identifier') {
|
|
3242
|
+
return EVAL_BUILTIN_IDENTS.has(callee.name) ? callee.name : null
|
|
3243
|
+
}
|
|
3244
|
+
if (
|
|
3245
|
+
callee.kind === 'member' &&
|
|
3246
|
+
!callee.computed &&
|
|
3247
|
+
callee.object.kind === 'identifier' &&
|
|
3248
|
+
callee.object.name === 'Math' &&
|
|
3249
|
+
EVAL_MATH_METHODS.has(callee.property)
|
|
3250
|
+
) {
|
|
3251
|
+
return `Math.${callee.property}`
|
|
3252
|
+
}
|
|
3253
|
+
return null
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
/** Build the evaluator's minimal node object, or null for an out-of-surface kind. */
|
|
3257
|
+
function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
|
|
3258
|
+
switch (e.kind) {
|
|
3259
|
+
case 'literal':
|
|
3260
|
+
return { kind: 'literal', value: e.value }
|
|
3261
|
+
case 'identifier':
|
|
3262
|
+
return { kind: 'identifier', name: e.name }
|
|
3263
|
+
case 'binary': {
|
|
3264
|
+
if (!EVAL_BINARY_OPS.has(e.op)) return null
|
|
3265
|
+
const left = toEvalNode(e.left)
|
|
3266
|
+
const right = toEvalNode(e.right)
|
|
3267
|
+
return left && right ? { kind: 'binary', op: e.op, left, right } : null
|
|
3268
|
+
}
|
|
3269
|
+
case 'logical': {
|
|
3270
|
+
// `op` is the fixed `&&` | `||` | `??` union — all evaluator-supported.
|
|
3271
|
+
const left = toEvalNode(e.left)
|
|
3272
|
+
const right = toEvalNode(e.right)
|
|
3273
|
+
return left && right ? { kind: 'logical', op: e.op, left, right } : null
|
|
3274
|
+
}
|
|
3275
|
+
case 'unary': {
|
|
3276
|
+
if (!EVAL_UNARY_OPS.has(e.op)) return null
|
|
3277
|
+
const argument = toEvalNode(e.argument)
|
|
3278
|
+
return argument ? { kind: 'unary', op: e.op, argument } : null
|
|
3279
|
+
}
|
|
3280
|
+
case 'conditional': {
|
|
3281
|
+
const test = toEvalNode(e.test)
|
|
3282
|
+
const consequent = toEvalNode(e.consequent)
|
|
3283
|
+
const alternate = toEvalNode(e.alternate)
|
|
3284
|
+
return test && consequent && alternate
|
|
3285
|
+
? { kind: 'conditional', test, consequent, alternate }
|
|
3286
|
+
: null
|
|
3287
|
+
}
|
|
3288
|
+
case 'member': {
|
|
3289
|
+
const object = toEvalNode(e.object)
|
|
3290
|
+
if (!object) return null
|
|
3291
|
+
// Carry `computed` only when set (absent reads as `false`): the evaluator
|
|
3292
|
+
// reads it to reject a computed builtin (`Math['max']`), so preserving it
|
|
3293
|
+
// keeps a computed member distinguishable from a plain `.prop` access. (A
|
|
3294
|
+
// computed builtin *call* is already refused by the callee gate above.)
|
|
3295
|
+
const node: Record<string, unknown> = { kind: 'member', object, property: e.property }
|
|
3296
|
+
if (e.computed) node.computed = true
|
|
3297
|
+
return node
|
|
3298
|
+
}
|
|
3299
|
+
case 'index-access': {
|
|
3300
|
+
const object = toEvalNode(e.object)
|
|
3301
|
+
const index = toEvalNode(e.index)
|
|
3302
|
+
return object && index ? { kind: 'index-access', object, index } : null
|
|
3303
|
+
}
|
|
3304
|
+
case 'call': {
|
|
3305
|
+
// The evaluator executes only the builtin allowlist; a non-builtin callee
|
|
3306
|
+
// would evaluate to nil at runtime, so refuse it here (the purity gate).
|
|
3307
|
+
if (evalBuiltinCalleeName(e.callee) === null) return null
|
|
3308
|
+
const callee = toEvalNode(e.callee)
|
|
3309
|
+
if (!callee) return null
|
|
3310
|
+
const args: Record<string, unknown>[] = []
|
|
3311
|
+
for (const a of e.args) {
|
|
3312
|
+
const c = toEvalNode(a)
|
|
3313
|
+
if (!c) return null
|
|
3314
|
+
args.push(c)
|
|
3315
|
+
}
|
|
3316
|
+
return { kind: 'call', callee, args }
|
|
3317
|
+
}
|
|
3318
|
+
case 'template-literal': {
|
|
3319
|
+
const parts: Record<string, unknown>[] = []
|
|
3320
|
+
for (const p of e.parts) {
|
|
3321
|
+
if (p.type === 'string') {
|
|
3322
|
+
parts.push({ type: 'string', value: p.value })
|
|
3323
|
+
} else {
|
|
3324
|
+
const expr = toEvalNode(p.expr)
|
|
3325
|
+
if (!expr) return null
|
|
3326
|
+
parts.push({ type: 'expression', expr })
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
return { kind: 'template-literal', parts }
|
|
3330
|
+
}
|
|
3331
|
+
case 'array-literal': {
|
|
3332
|
+
const elements: Record<string, unknown>[] = []
|
|
3333
|
+
for (const el of e.elements) {
|
|
3334
|
+
const c = toEvalNode(el)
|
|
3335
|
+
if (!c) return null
|
|
3336
|
+
elements.push(c)
|
|
3337
|
+
}
|
|
3338
|
+
return { kind: 'array-literal', elements }
|
|
3339
|
+
}
|
|
3340
|
+
case 'object-literal': {
|
|
3341
|
+
const properties: Record<string, unknown>[] = []
|
|
3342
|
+
for (const p of e.properties) {
|
|
3343
|
+
const value = toEvalNode(p.value)
|
|
3344
|
+
if (!value) return null
|
|
3345
|
+
properties.push({ key: p.key, value })
|
|
3346
|
+
}
|
|
3347
|
+
return { kind: 'object-literal', properties }
|
|
3348
|
+
}
|
|
3349
|
+
// Outside the evaluator's pure-expression surface — refuse so the caller
|
|
3350
|
+
// falls back to BF101 / `@client`. A nested `arrow` (a callback inside the
|
|
3351
|
+
// body) is refused here, keeping the evaluator non-recursive.
|
|
3352
|
+
case 'array-method':
|
|
3353
|
+
case 'arrow':
|
|
3354
|
+
case 'regex':
|
|
3355
|
+
case 'unsupported':
|
|
3356
|
+
return null
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3104
3360
|
/**
|
|
3105
3361
|
* Extract the textual identifier path from a parsed expression's
|
|
3106
3362
|
* callee — `{kind:'identifier', name:'String'}` → `"String"`,
|