@barefootjs/jsx 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/env-signal.d.ts +73 -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/compiler.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +264 -163
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7839 -7019
- 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/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/ssr-seed-plan.d.ts +84 -0
- package/dist/ssr-seed-plan.d.ts.map +1 -0
- package/dist/types.d.ts +180 -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 +714 -392
- package/src/__tests__/free-identifiers.test.ts +55 -0
- 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__/materialize-getter-calls.test.ts +58 -0
- package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
- package/src/__tests__/query-href-recognition.test.ts +58 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +223 -0
- package/src/__tests__/ssr-seed-plan.test.ts +212 -0
- package/src/__tests__/unsupported-expression.test.ts +98 -4
- package/src/adapters/env-signal.ts +108 -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/compiler.ts +6 -1
- package/src/expression-parser.ts +1375 -929
- package/src/index.ts +31 -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/query-href-lowering.ts +147 -0
- package/src/ssr-defaults.ts +5 -1
- package/src/ssr-seed-plan.ts +146 -0
- package/src/types.ts +182 -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 }
|
|
@@ -335,8 +247,12 @@ export interface SupportResult {
|
|
|
335
247
|
const UNSUPPORTED_METHODS = new Set([
|
|
336
248
|
// Higher-order array methods. Seven of these (`filter`, `every`,
|
|
337
249
|
// `some`, `find`, `findIndex`, `findLast`, `findLastIndex`) are
|
|
338
|
-
// intercepted as `higher-order` IR before reaching this gate
|
|
339
|
-
// `map` is intercepted as an IRLoop
|
|
250
|
+
// intercepted as `higher-order` IR before reaching this gate.
|
|
251
|
+
// `map` is intercepted as an IRLoop when its callback returns JSX,
|
|
252
|
+
// and as a `CALLBACK_METHODS` evaluator lowering (`map_eval`, #2073)
|
|
253
|
+
// when it returns a value — it stays listed here so the fall-throughs
|
|
254
|
+
// (a bare `arr.map` reference, a function-reference callback) still
|
|
255
|
+
// refuse loudly. `reduce` / `reduceRight` stay
|
|
340
256
|
// listed here so the shapes the Tier C catalogue can't lower still
|
|
341
257
|
// refuse loudly: the `convertNode` call branch intercepts a matching
|
|
342
258
|
// `.reduce(fn, init)` / `.reduceRight(fn, init)` into the structured
|
|
@@ -669,6 +585,73 @@ export function parseExpression(expr: string): ParsedExpr {
|
|
|
669
585
|
return convertNode(firstStmt.expression, expr)
|
|
670
586
|
}
|
|
671
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Convert an already-parsed TypeScript node directly into a {@link ParsedExpr},
|
|
590
|
+
* for a consumer that already holds a `ts.Node` (e.g. a `.sort()` callback at
|
|
591
|
+
* the loop-hoist site) and wants the structured conversion without re-parsing
|
|
592
|
+
* source via `ts.createSourceFile`. An `unsupported` result still carries the
|
|
593
|
+
* node's text in `raw` for debugging (a synthetic node with no source file
|
|
594
|
+
* falls back to '').
|
|
595
|
+
*/
|
|
596
|
+
export function tsNodeToParsedExpr(node: ts.Node): ParsedExpr {
|
|
597
|
+
let raw = ''
|
|
598
|
+
try {
|
|
599
|
+
raw = node.getText()
|
|
600
|
+
} catch {
|
|
601
|
+
/* synthetic node without a source file */
|
|
602
|
+
}
|
|
603
|
+
return convertNode(node, raw)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Higher-order array methods whose callback body the runtime evaluator drives
|
|
608
|
+
* (#2018). Recognised generically as a `call` whose callee is `<recv>.<method>`
|
|
609
|
+
* and whose first argument is an `arrow`; the adapter serializes the arrow body
|
|
610
|
+
* to the evaluator. A JSX-returning `.map` / `.flatMap` is an IRLoop upstream
|
|
611
|
+
* and never reaches this recognition; the value-returning `.map(cb)` form
|
|
612
|
+
* (e.g. `tags.map(t => \`#${t}\`).join(' ')`) lowers via `map_eval` (#2073).
|
|
613
|
+
*/
|
|
614
|
+
export const CALLBACK_METHODS: ReadonlySet<string> = new Set([
|
|
615
|
+
'filter', 'map', 'every', 'some', 'find', 'findIndex', 'findLast', 'findLastIndex',
|
|
616
|
+
'sort', 'toSorted', 'reduce', 'reduceRight', 'flatMap',
|
|
617
|
+
])
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* A recognised higher-order callback method call: `<object>.<method>(<arrow>,
|
|
621
|
+
* …rest)` where `method ∈` {@link CALLBACK_METHODS} and the first argument is a
|
|
622
|
+
* generic `arrow`. Returns the receiver, the callback arrow, and any trailing
|
|
623
|
+
* args (e.g. the `.reduce` init), or `null` if the shape doesn't match. The
|
|
624
|
+
* single recognition point shared by the support gate and the adapter dispatch.
|
|
625
|
+
*/
|
|
626
|
+
export function asCallbackMethodCall(expr: ParsedExpr): {
|
|
627
|
+
method: string
|
|
628
|
+
object: ParsedExpr
|
|
629
|
+
arrow: Extract<ParsedExpr, { kind: 'arrow' }>
|
|
630
|
+
args: ParsedExpr[]
|
|
631
|
+
} | null {
|
|
632
|
+
if (expr.kind !== 'call') return null
|
|
633
|
+
if (expr.callee.kind !== 'member' || expr.callee.computed) return null
|
|
634
|
+
if (!CALLBACK_METHODS.has(expr.callee.property)) return null
|
|
635
|
+
const arrow = expr.args[0]
|
|
636
|
+
if (!arrow || arrow.kind !== 'arrow') return null
|
|
637
|
+
return { method: expr.callee.property, object: expr.callee.object, arrow, args: expr.args.slice(1) }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Resolve a non-computed object-literal property key to its string name.
|
|
642
|
+
* Identifier / string / numeric names resolve to their text; a computed
|
|
643
|
+
* (`[expr]`) or otherwise non-plain key returns null so the caller treats
|
|
644
|
+
* the whole literal as `unsupported`.
|
|
645
|
+
*/
|
|
646
|
+
function objectLiteralKeyName(
|
|
647
|
+
name: ts.PropertyName,
|
|
648
|
+
): { key: string; keyKind: 'identifier' | 'string' | 'numeric' } | null {
|
|
649
|
+
if (ts.isIdentifier(name)) return { key: name.text, keyKind: 'identifier' }
|
|
650
|
+
if (ts.isStringLiteral(name)) return { key: name.text, keyKind: 'string' }
|
|
651
|
+
if (ts.isNumericLiteral(name)) return { key: name.text, keyKind: 'numeric' }
|
|
652
|
+
return null
|
|
653
|
+
}
|
|
654
|
+
|
|
672
655
|
/**
|
|
673
656
|
* Convert a TypeScript AST node to ParsedExpr.
|
|
674
657
|
*/
|
|
@@ -683,10 +666,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
683
666
|
return { kind: 'literal', value: node.text, literalType: 'string' }
|
|
684
667
|
}
|
|
685
668
|
|
|
686
|
-
// Numeric literal: 0, 5, 3.14
|
|
669
|
+
// Numeric literal: 0, 5, 3.14. Keep `ts.NumericLiteral.text` in `raw` (TS's
|
|
670
|
+
// normalised token — `1_000`/`0x10`/`1e3` → `1000`/`16`/`1000`) so an adapter
|
|
671
|
+
// emits the exact string its own literal lowering already produces; `value`
|
|
672
|
+
// is the parsed number for structural reasoning.
|
|
687
673
|
if (ts.isNumericLiteral(node)) {
|
|
688
674
|
const value = parseFloat(node.text)
|
|
689
|
-
return { kind: 'literal', value, literalType: 'number' }
|
|
675
|
+
return { kind: 'literal', value, literalType: 'number', raw: node.text }
|
|
690
676
|
}
|
|
691
677
|
|
|
692
678
|
// Boolean literals and null
|
|
@@ -705,37 +691,26 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
705
691
|
const callee = convertNode(node.expression, raw)
|
|
706
692
|
const args = node.arguments.map(arg => convertNode(arg, raw))
|
|
707
693
|
|
|
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
|
-
}
|
|
694
|
+
// Higher-order callback methods (`.filter`/`.find`/`.every`/`.some`/
|
|
695
|
+
// `.sort`/`.reduce`/`.flatMap`/…) are NOT folded here (#2018 P5): they
|
|
696
|
+
// flow through as a generic `call` whose argument is a generic `arrow`,
|
|
697
|
+
// and the adapter recognises the callback shape at dispatch and serializes
|
|
698
|
+
// the arrow body to the runtime evaluator. The one exception is the
|
|
699
|
+
// non-arrow `.filter(Boolean)` callable: synthesise the equivalent
|
|
700
|
+
// identity arrow `_ => _` so it flows through the same callback lowering
|
|
701
|
+
// (the adapter keeps a dedicated truthiness fallback for the identity body).
|
|
702
|
+
if (
|
|
703
|
+
callee.kind === 'member' &&
|
|
704
|
+
!callee.computed &&
|
|
705
|
+
callee.property === 'filter' &&
|
|
706
|
+
args.length === 1 &&
|
|
707
|
+
args[0].kind === 'identifier' &&
|
|
708
|
+
args[0].name === 'Boolean'
|
|
709
|
+
) {
|
|
710
|
+
return {
|
|
711
|
+
kind: 'call',
|
|
712
|
+
callee,
|
|
713
|
+
args: [{ kind: 'arrow', params: ['_'], body: { kind: 'identifier', name: '_' } }],
|
|
739
714
|
}
|
|
740
715
|
}
|
|
741
716
|
|
|
@@ -931,9 +906,12 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
931
906
|
// replacing the FIRST occurrence (JS semantics for a string
|
|
932
907
|
// pattern). Go uses `bf_replace` (`strings.Replace` with n=1);
|
|
933
908
|
// Mojo uses `bf->replace` (index/substr splice, no regex). A
|
|
934
|
-
// regex-literal pattern
|
|
935
|
-
//
|
|
936
|
-
//
|
|
909
|
+
// regex-literal pattern is the deferred form — its `regex` first
|
|
910
|
+
// arg is carried STRUCTURALLY (not collapsed to `unsupported`) so
|
|
911
|
+
// the Go ctor lowering can recover the one trailing-slash pattern
|
|
912
|
+
// it supports without re-parsing (#2039). Template use stays
|
|
913
|
+
// refused: `isSupported` maps a regex-pattern `.replace` to the
|
|
914
|
+
// deferred-form BF101 reason — the Perl `s///` vs Go
|
|
937
915
|
// `regexp.ReplaceAllString` flavour gap is the open design
|
|
938
916
|
// question in #1448. `replaceAll` stays refused entirely.
|
|
939
917
|
//
|
|
@@ -951,28 +929,30 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
951
929
|
}
|
|
952
930
|
}
|
|
953
931
|
// A regex-literal pattern is the deferred form (the Perl `s///`
|
|
954
|
-
// vs Go `regexp.ReplaceAllString` flavour gap, #1448)
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
932
|
+
// vs Go `regexp.ReplaceAllString` flavour gap, #1448). Its shape
|
|
933
|
+
// is carried structurally — `args[0]` is a `regex` node (convertNode
|
|
934
|
+
// line ~1127) — so a consumer that recognises one fixed pattern (the
|
|
935
|
+
// Go ctor's `/\/+$/` trailing-slash strip → `strings.TrimRight`) reads
|
|
936
|
+
// it from the tree instead of re-parsing the raw (#2039). Returned
|
|
937
|
+
// before the object-literal `badArg` check below so the regex form
|
|
938
|
+
// keeps its dedicated diagnostic precedence; `isSupported` then refuses
|
|
939
|
+
// any template use with the deferred-form reason.
|
|
959
940
|
const patternNode = node.arguments[0]
|
|
960
941
|
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
|
-
}
|
|
942
|
+
return { kind: 'array-method', method: 'replace', object: callee.object, args }
|
|
967
943
|
}
|
|
944
|
+
// Treat an object-literal argument like `unsupported` — a `.replace`
|
|
945
|
+
// with an object pattern/replacement isn't lowerable, same as before
|
|
946
|
+
// the `object-literal` kind existed (byte-identical; Roadmap A-1).
|
|
968
947
|
const badArg =
|
|
969
|
-
args[0].kind === 'unsupported'
|
|
948
|
+
args[0].kind === 'unsupported' || args[0].kind === 'object-literal'
|
|
970
949
|
? args[0]
|
|
971
|
-
: args[1].kind === 'unsupported'
|
|
950
|
+
: args[1].kind === 'unsupported' || args[1].kind === 'object-literal'
|
|
972
951
|
? args[1]
|
|
973
952
|
: undefined
|
|
974
|
-
if (badArg
|
|
975
|
-
|
|
953
|
+
if (badArg) {
|
|
954
|
+
const reason = badArg.kind === 'unsupported' ? badArg.reason : 'Unsupported syntax: ObjectLiteralExpression'
|
|
955
|
+
return { kind: 'unsupported', raw, reason }
|
|
976
956
|
}
|
|
977
957
|
return { kind: 'array-method', method: 'replace', object: callee.object, args }
|
|
978
958
|
}
|
|
@@ -1004,123 +984,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1004
984
|
if (callee.property === 'padStart' || callee.property === 'padEnd') {
|
|
1005
985
|
return { kind: 'array-method', method: callee.property, object: callee.object, args }
|
|
1006
986
|
}
|
|
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
|
-
}
|
|
987
|
+
// `.sort` / `.toSorted` / `.reduce` / `.reduceRight` / `.flatMap`
|
|
988
|
+
// (callback methods) are NOT folded here (#2018 P5): they fall through
|
|
989
|
+
// to the generic `call` construction below (callee = member, arg =
|
|
990
|
+
// generic `arrow`), and the adapter serializes the arrow body to the
|
|
991
|
+
// runtime evaluator. The localeCompare-sort fallback recovers a
|
|
992
|
+
// structured comparator from the generic arrow via
|
|
993
|
+
// `sortComparatorFromArrow`.
|
|
1124
994
|
}
|
|
1125
995
|
|
|
1126
996
|
return { kind: 'call', callee, args }
|
|
@@ -1132,6 +1002,29 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1132
1002
|
return { kind: 'array-literal', elements }
|
|
1133
1003
|
}
|
|
1134
1004
|
|
|
1005
|
+
// Object literal: { a: 1, b: x, shorthand }. Only plain literals are
|
|
1006
|
+
// structured — every property must be a non-computed `key: value` or a
|
|
1007
|
+
// shorthand `{ key }`. Anything else (spread, computed key, method,
|
|
1008
|
+
// getter/setter) falls through to the generic `unsupported` fallback
|
|
1009
|
+
// below, exactly as before this kind existed.
|
|
1010
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
1011
|
+
const properties: ObjectLiteralProperty[] = []
|
|
1012
|
+
for (const prop of node.properties) {
|
|
1013
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
1014
|
+
const k = objectLiteralKeyName(prop.name)
|
|
1015
|
+
if (k === null) return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
|
|
1016
|
+
properties.push({ key: k.key, keyKind: k.keyKind, shorthand: false, value: convertNode(prop.initializer, raw) })
|
|
1017
|
+
} else if (ts.isShorthandPropertyAssignment(prop)) {
|
|
1018
|
+
const key = prop.name.text
|
|
1019
|
+
properties.push({ key, keyKind: 'identifier', shorthand: true, value: { kind: 'identifier', name: key } })
|
|
1020
|
+
} else {
|
|
1021
|
+
// Spread assignment, method, getter/setter — not a plain map.
|
|
1022
|
+
return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return { kind: 'object-literal', properties, raw }
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1135
1028
|
// Property access: user.name, items().length
|
|
1136
1029
|
if (ts.isPropertyAccessExpression(node)) {
|
|
1137
1030
|
const object = convertNode(node.expression, raw)
|
|
@@ -1164,7 +1057,10 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1164
1057
|
// (the literal forms above fold into a static property path; this
|
|
1165
1058
|
// one can't). #1897 (data-table).
|
|
1166
1059
|
const index = convertNode(argNode, raw)
|
|
1167
|
-
|
|
1060
|
+
// An object-literal index (`arr[{…}]`) isn't lowerable — surface it
|
|
1061
|
+
// as the whole expression, exactly as an `unsupported` index did
|
|
1062
|
+
// before the kind existed (byte-identical; Roadmap A-1).
|
|
1063
|
+
if (index.kind === 'unsupported' || index.kind === 'object-literal') return index
|
|
1168
1064
|
return { kind: 'index-access', object, index }
|
|
1169
1065
|
}
|
|
1170
1066
|
|
|
@@ -1232,24 +1128,63 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1232
1128
|
return { kind: 'literal', value: node.text, literalType: 'string' }
|
|
1233
1129
|
}
|
|
1234
1130
|
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1131
|
+
// Regex literal: `/\/+$/`. Carried as exact source text for the Go ctor
|
|
1132
|
+
// trailing-slash-strip lowering (`String.replace`).
|
|
1133
|
+
if (ts.isRegularExpressionLiteral(node)) {
|
|
1134
|
+
return { kind: 'regex', raw: node.getText() }
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Arrow function / function expression: `x => expr`, `(a, b) => expr`,
|
|
1138
|
+
// `({field}) => field`, `function (x) { return … }`. Produces a generic
|
|
1139
|
+
// multi-parameter `arrow` (#2018 P5). A single-`return` block body
|
|
1140
|
+
// normalises to its returned expression; a single object-binding-pattern
|
|
1141
|
+
// param is rewritten to a synthetic identifier param with dotted-access
|
|
1142
|
+
// body (destructure support). Higher-order callbacks reach the adapter as
|
|
1143
|
+
// a generic `call` whose argument is this kind; the adapter serializes
|
|
1144
|
+
// `body` to the runtime evaluator.
|
|
1145
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
1146
|
+
// Resolve the body expression: an expression-bodied arrow carries it
|
|
1147
|
+
// directly; a block body (arrow `=> { … }` or a function expression)
|
|
1148
|
+
// must reduce to exactly one `return <expr>;`. Multi-statement /
|
|
1149
|
+
// local-var bodies stay refused.
|
|
1150
|
+
let body: ParsedExpr
|
|
1151
|
+
if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
|
|
1152
|
+
body = convertNode(node.body, raw)
|
|
1153
|
+
} else {
|
|
1154
|
+
const block = node.body as ts.Block
|
|
1155
|
+
const stmts = block.statements
|
|
1156
|
+
// Fast path: a single `return <expr>` keeps the pre-#2040 conversion
|
|
1157
|
+
// (convertNode on the returned node), byte-identical for the existing
|
|
1158
|
+
// corpus.
|
|
1159
|
+
if (stmts.length === 1 && ts.isReturnStatement(stmts[0]) && stmts[0].expression) {
|
|
1160
|
+
body = convertNode(stmts[0].expression, raw)
|
|
1161
|
+
} else {
|
|
1162
|
+
// General value-producing block: normalize `let`-inline + value `if` /
|
|
1163
|
+
// early `return` into a single expression (#2040). Imperative shapes
|
|
1164
|
+
// (raw `for` / `while`, `break`, mutation, side effects) don't parse
|
|
1165
|
+
// into ParsedStatement, or fall through without a value — either way we
|
|
1166
|
+
// refuse with an actionable reason and adapters surface BF101.
|
|
1167
|
+
let sf: ts.SourceFile | undefined
|
|
1168
|
+
try {
|
|
1169
|
+
sf = block.getSourceFile()
|
|
1170
|
+
} catch {
|
|
1171
|
+
sf = undefined
|
|
1172
|
+
}
|
|
1173
|
+
const parsed = sf
|
|
1174
|
+
? parseBlockBody(block, sf, n => n.getText(sf))
|
|
1175
|
+
: null
|
|
1176
|
+
if (!parsed) {
|
|
1177
|
+
return { kind: 'unsupported', raw, reason: IMPERATIVE_BLOCK_REASON }
|
|
1178
|
+
}
|
|
1179
|
+
const folded = foldBlockToExpr(parsed)
|
|
1180
|
+
if (!folded.ok) {
|
|
1181
|
+
return { kind: 'unsupported', raw, reason: folded.reason }
|
|
1182
|
+
}
|
|
1183
|
+
body = folded.expr
|
|
1184
|
+
}
|
|
1249
1185
|
}
|
|
1250
|
-
const body = convertNode(node.body, raw)
|
|
1251
1186
|
|
|
1252
|
-
//
|
|
1187
|
+
// Single object-binding-pattern param: `({done}) => done` (#1443),
|
|
1253
1188
|
// `({user: {name}}) => name` (#1530), `({done = false}) => done`
|
|
1254
1189
|
// (#1531), `({done, ...rest}) => rest.priority` (#1532). We
|
|
1255
1190
|
// synthesise the equivalent dotted-access form so adapters can
|
|
@@ -1262,7 +1197,8 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1262
1197
|
// shapes refuse with BF021 (#1532). Array binding patterns,
|
|
1263
1198
|
// nested rest, and defaults at non-leaf (nested-pattern) slots
|
|
1264
1199
|
// stay unsupported.
|
|
1265
|
-
if (ts.isObjectBindingPattern(
|
|
1200
|
+
if (node.parameters.length === 1 && ts.isObjectBindingPattern(node.parameters[0].name)) {
|
|
1201
|
+
const bindingPattern = node.parameters[0].name
|
|
1266
1202
|
const fieldMap = new Map<string, DestructureBinding>()
|
|
1267
1203
|
// `excludedTopKeys` mirrors the JS rest-binding exclusion set:
|
|
1268
1204
|
// the source-object keys explicitly consumed at the top level
|
|
@@ -1271,7 +1207,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1271
1207
|
// keys on local rename / leaf names and would miss
|
|
1272
1208
|
// `({done: d, ...rest}) => rest.done` or `({user: {name}, ...rest}) => rest.user`.
|
|
1273
1209
|
const excludedTopKeys = new Set<string>()
|
|
1274
|
-
const collect = collectDestructureBindings(
|
|
1210
|
+
const collect = collectDestructureBindings(bindingPattern, [], fieldMap, raw, excludedTopKeys)
|
|
1275
1211
|
if (!collect.ok) {
|
|
1276
1212
|
return { kind: 'unsupported', raw, reason: collect.reason }
|
|
1277
1213
|
}
|
|
@@ -1335,37 +1271,20 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1335
1271
|
}
|
|
1336
1272
|
const syntheticParam = pickSyntheticParam(fieldMap, body)
|
|
1337
1273
|
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' }
|
|
1274
|
+
return { kind: 'arrow', params: [syntheticParam], body: rewritten }
|
|
1343
1275
|
}
|
|
1344
|
-
return { kind: 'arrow-fn', param: param.name.text, body }
|
|
1345
|
-
}
|
|
1346
1276
|
|
|
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),
|
|
1277
|
+
// Every (remaining) parameter must be a plain identifier. Multi-param
|
|
1278
|
+
// arrows (sort comparators `(a, b) => …`) and single-param identifier
|
|
1279
|
+
// arrows (`x => …`) both land here.
|
|
1280
|
+
const params: string[] = []
|
|
1281
|
+
for (const p of node.parameters) {
|
|
1282
|
+
if (!ts.isIdentifier(p.name)) {
|
|
1283
|
+
return { kind: 'unsupported', raw, reason: 'Only identifier (or a single object-destructure) function parameters are supported' }
|
|
1284
|
+
}
|
|
1285
|
+
params.push(p.name.text)
|
|
1368
1286
|
}
|
|
1287
|
+
return { kind: 'arrow', params, body }
|
|
1369
1288
|
}
|
|
1370
1289
|
|
|
1371
1290
|
// Default: unsupported
|
|
@@ -1373,114 +1292,56 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
1373
1292
|
}
|
|
1374
1293
|
|
|
1375
1294
|
/**
|
|
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.
|
|
1295
|
+
* Recover a {@link SortComparator} from a generic `(a, b) => …` sort callback
|
|
1296
|
+
* arrow (#2018 P5). The LEGACY fallback for a comparator the runtime evaluator
|
|
1297
|
+
* can't model (`localeCompare` string sorts — `serializeParsedExpr` refuses
|
|
1298
|
+
* them); the adapter calls this only when the eval path returns null. Operates
|
|
1299
|
+
* on the generic `arrow` ParsedExpr (params + body subtree) — no `ts` re-parse.
|
|
1388
1300
|
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
* and function expression. Multi-statement / local-var bodies stay
|
|
1393
|
-
* refused (deferred follow-up).
|
|
1301
|
+
* The accepted catalogue is finite so the walker stays shallow — no constant
|
|
1302
|
+
* folding, no symbol resolution. Returns null if the shape doesn't match
|
|
1303
|
+
* exactly, in which case the adapter surfaces BF101.
|
|
1394
1304
|
*
|
|
1395
|
-
* A body is split on top-level `||` into one leaf per operand, giving
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
* order):
|
|
1305
|
+
* A body is split on top-level `||` into one leaf per operand, giving a
|
|
1306
|
+
* multi-key comparator (`a.x - b.x || a.y - b.y` → sort by x, then y). Accepted
|
|
1307
|
+
* leaf shapes (each paired ascending / descending by operand order):
|
|
1399
1308
|
*
|
|
1400
1309
|
* a.field - b.field → field, numeric
|
|
1401
1310
|
* a - b → self, numeric
|
|
1402
1311
|
* a.field.localeCompare(b.field) → field, string
|
|
1403
1312
|
* a.localeCompare(b) → self, string
|
|
1404
1313
|
* a.field > b.field ? 1 : -1 → field, auto (relational ternary)
|
|
1405
|
-
* a.field < b.field ? -1 : 1 → field, auto
|
|
1406
1314
|
* a < b ? -1 : a > b ? 1 : 0 → self/field, auto (3-way)
|
|
1407
1315
|
* a === b ? 0 : <relational ternary> → leading-tie 3-way
|
|
1408
1316
|
*
|
|
1409
|
-
* Function-reference comparators and `localeCompare(b, locale, opts)`
|
|
1410
|
-
*
|
|
1317
|
+
* Function-reference comparators and `localeCompare(b, locale, opts)` (the
|
|
1318
|
+
* multi-arg form) return null — deferred follow-ups.
|
|
1411
1319
|
*/
|
|
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).
|
|
1320
|
+
export function sortComparatorFromArrow(arrow: ParsedExpr): SortComparator | null {
|
|
1321
|
+
if (arrow.kind !== 'arrow' || arrow.params.length !== 2) return null
|
|
1322
|
+
const [paramA, paramB] = arrow.params
|
|
1323
|
+
|
|
1324
|
+
// A `||`-chain is a multi-key comparator: each operand is an independent
|
|
1325
|
+
// leaf applied as the next tie-breaker. A non-`||` body is single-key.
|
|
1455
1326
|
const keys: SortKey[] = []
|
|
1456
|
-
for (const operand of flattenLogicalOr(body)) {
|
|
1327
|
+
for (const operand of flattenLogicalOr(arrow.body)) {
|
|
1457
1328
|
const key = classifyLeafComparator(operand, paramA, paramB)
|
|
1458
1329
|
if (!key) return null
|
|
1459
1330
|
keys.push(key)
|
|
1460
1331
|
}
|
|
1461
1332
|
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
|
|
1333
|
+
return { keys }
|
|
1471
1334
|
}
|
|
1472
1335
|
|
|
1473
1336
|
/**
|
|
1474
|
-
* Flatten a
|
|
1475
|
-
* `a || b || c` parses as `((a || b) || c)`; this returns `[a, b, c]`.
|
|
1337
|
+
* Flatten a top-level `||` chain into its operands (`a || b || c` → `[a, b, c]`).
|
|
1476
1338
|
* A non-`||` expression returns a single-element list.
|
|
1477
1339
|
*/
|
|
1478
|
-
function flattenLogicalOr(expr:
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
return [...flattenLogicalOr(inner.left), ...flattenLogicalOr(inner.right)]
|
|
1340
|
+
function flattenLogicalOr(expr: ParsedExpr): ParsedExpr[] {
|
|
1341
|
+
if (expr.kind === 'logical' && expr.op === '||') {
|
|
1342
|
+
return [...flattenLogicalOr(expr.left), ...flattenLogicalOr(expr.right)]
|
|
1482
1343
|
}
|
|
1483
|
-
return [
|
|
1344
|
+
return [expr]
|
|
1484
1345
|
}
|
|
1485
1346
|
|
|
1486
1347
|
/**
|
|
@@ -1488,60 +1349,40 @@ function flattenLogicalOr(expr: ts.Expression): ts.Expression[] {
|
|
|
1488
1349
|
* Accepts subtraction (numeric), `localeCompare` (string), and
|
|
1489
1350
|
* relational-ternary (auto) shapes; returns null otherwise.
|
|
1490
1351
|
*/
|
|
1491
|
-
function classifyLeafComparator(
|
|
1492
|
-
expr: ts.Expression,
|
|
1493
|
-
paramA: string,
|
|
1494
|
-
paramB: string,
|
|
1495
|
-
): SortKey | null {
|
|
1496
|
-
const body = unwrapParens(expr)
|
|
1497
|
-
|
|
1352
|
+
function classifyLeafComparator(expr: ParsedExpr, paramA: string, paramB: string): SortKey | null {
|
|
1498
1353
|
// Subtraction: `a.field - b.field` / `a - b` → numeric.
|
|
1499
|
-
if (
|
|
1500
|
-
return classifyComparatorOperands(
|
|
1354
|
+
if (expr.kind === 'binary' && expr.op === '-') {
|
|
1355
|
+
return classifyComparatorOperands(expr.left, expr.right, paramA, paramB, 'numeric')
|
|
1501
1356
|
}
|
|
1502
1357
|
|
|
1503
|
-
// localeCompare (zero-arg form): `<lhs>.localeCompare(<rhs>)` →
|
|
1504
|
-
//
|
|
1505
|
-
// needs per-adapter collation plumbing (deferred follow-up).
|
|
1358
|
+
// localeCompare (zero-arg form): `<lhs>.localeCompare(<rhs>)` → string. The
|
|
1359
|
+
// locale/options form (2–3 args) stays refused.
|
|
1506
1360
|
if (
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1361
|
+
expr.kind === 'call' &&
|
|
1362
|
+
expr.callee.kind === 'member' &&
|
|
1363
|
+
expr.callee.property === 'localeCompare' &&
|
|
1364
|
+
expr.args.length === 1
|
|
1511
1365
|
) {
|
|
1512
|
-
return classifyComparatorOperands(
|
|
1513
|
-
body.expression.expression, // receiver of .localeCompare
|
|
1514
|
-
body.arguments[0],
|
|
1515
|
-
paramA,
|
|
1516
|
-
paramB,
|
|
1517
|
-
'string',
|
|
1518
|
-
)
|
|
1366
|
+
return classifyComparatorOperands(expr.callee.object, expr.args[0], paramA, paramB, 'string')
|
|
1519
1367
|
}
|
|
1520
1368
|
|
|
1521
1369
|
// Relational-ternary sign comparator → auto.
|
|
1522
|
-
if (
|
|
1523
|
-
return classifyTernaryComparator(
|
|
1370
|
+
if (expr.kind === 'conditional') {
|
|
1371
|
+
return classifyTernaryComparator(expr, paramA, paramB)
|
|
1524
1372
|
}
|
|
1525
1373
|
|
|
1526
1374
|
return null
|
|
1527
1375
|
}
|
|
1528
1376
|
|
|
1529
1377
|
/**
|
|
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.
|
|
1378
|
+
* Classify two operands against the comparator's two param names. Both must
|
|
1379
|
+
* resolve to either the param identifier itself (`self`) or a single-level
|
|
1380
|
+
* field access on it (`field`), reference different params, and match on key
|
|
1381
|
+
* shape + field name. `paramA` first is ascending, reversed is descending.
|
|
1541
1382
|
*/
|
|
1542
1383
|
function classifyComparatorOperands(
|
|
1543
|
-
left:
|
|
1544
|
-
right:
|
|
1384
|
+
left: ParsedExpr,
|
|
1385
|
+
right: ParsedExpr,
|
|
1545
1386
|
paramA: string,
|
|
1546
1387
|
paramB: string,
|
|
1547
1388
|
type: 'numeric' | 'string',
|
|
@@ -1560,44 +1401,35 @@ function classifyComparatorOperands(
|
|
|
1560
1401
|
|
|
1561
1402
|
/**
|
|
1562
1403
|
* 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.
|
|
1404
|
+
* Handles the 2-way sign form (`a.f > b.f ? 1 : -1`), the canonical 3-way
|
|
1405
|
+
* (`a.f < b.f ? -1 : a.f > b.f ? 1 : 0`), and a leading equality tie
|
|
1406
|
+
* (`a.f === b.f ? 0 : <relational ternary>`).
|
|
1571
1407
|
*/
|
|
1572
1408
|
function classifyTernaryComparator(
|
|
1573
|
-
node:
|
|
1409
|
+
node: Extract<ParsedExpr, { kind: 'conditional' }>,
|
|
1574
1410
|
paramA: string,
|
|
1575
1411
|
paramB: string,
|
|
1576
1412
|
): SortKey | null {
|
|
1577
|
-
const cond =
|
|
1413
|
+
const cond = node.test
|
|
1578
1414
|
|
|
1579
|
-
// Leading equality tie: `a.f === b.f ? 0 : <ternary>`.
|
|
1580
|
-
// arm returns 0 (tie); the real ordering lives in the else branch.
|
|
1415
|
+
// Leading equality tie: `a.f === b.f ? 0 : <ternary>`.
|
|
1581
1416
|
if (
|
|
1582
|
-
|
|
1583
|
-
(cond.
|
|
1584
|
-
cond.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken) &&
|
|
1417
|
+
cond.kind === 'binary' &&
|
|
1418
|
+
(cond.op === '===' || cond.op === '==') &&
|
|
1585
1419
|
sameKeyOperands(cond.left, cond.right, paramA, paramB) &&
|
|
1586
|
-
numericSign(node.
|
|
1420
|
+
numericSign(node.consequent) === 0
|
|
1587
1421
|
) {
|
|
1588
|
-
const elseBranch =
|
|
1589
|
-
if (
|
|
1422
|
+
const elseBranch = node.alternate
|
|
1423
|
+
if (elseBranch.kind === 'conditional') {
|
|
1590
1424
|
return classifyTernaryComparator(elseBranch, paramA, paramB)
|
|
1591
1425
|
}
|
|
1592
1426
|
return null
|
|
1593
1427
|
}
|
|
1594
1428
|
|
|
1595
1429
|
// 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
|
|
1430
|
+
if (cond.kind !== 'binary') return null
|
|
1431
|
+
const isGreater = cond.op === '>' || cond.op === '>='
|
|
1432
|
+
const isLess = cond.op === '<' || cond.op === '<='
|
|
1601
1433
|
if (!isGreater && !isLess) return null
|
|
1602
1434
|
|
|
1603
1435
|
const leftRef = classifySortOperand(cond.left, paramA, paramB)
|
|
@@ -1609,37 +1441,21 @@ function classifyTernaryComparator(
|
|
|
1609
1441
|
return null
|
|
1610
1442
|
}
|
|
1611
1443
|
|
|
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)
|
|
1444
|
+
// whenTrue must be a non-zero sign literal (±1); whenFalse a bounded shape
|
|
1445
|
+
// (sign literal or a nested ternary on the same key). Direction is derived
|
|
1446
|
+
// solely from this outer comparison.
|
|
1447
|
+
const trueSign = numericSign(node.consequent)
|
|
1623
1448
|
if (trueSign === null || trueSign === 0) return null
|
|
1624
|
-
if (!isBoundedTernaryElse(node.
|
|
1449
|
+
if (!isBoundedTernaryElse(node.alternate, leftRef.key, paramA, paramB)) return null
|
|
1625
1450
|
|
|
1626
1451
|
// 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
1452
|
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
1453
|
const asc = greaterForA ? trueSign > 0 : trueSign < 0
|
|
1633
1454
|
return { key: leftRef.key, type: 'auto', direction: asc ? 'asc' : 'desc' }
|
|
1634
1455
|
}
|
|
1635
1456
|
|
|
1636
1457
|
/** 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 {
|
|
1458
|
+
function sameKeyOperands(left: ParsedExpr, right: ParsedExpr, paramA: string, paramB: string): boolean {
|
|
1643
1459
|
const l = classifySortOperand(left, paramA, paramB)
|
|
1644
1460
|
const r = classifySortOperand(right, paramA, paramB)
|
|
1645
1461
|
if (!l || !r) return false
|
|
@@ -1650,17 +1466,16 @@ function sameKeyOperands(
|
|
|
1650
1466
|
}
|
|
1651
1467
|
|
|
1652
1468
|
/**
|
|
1653
|
-
* Sign of a numeric literal with optional unary minus: 1, -1, or 0.
|
|
1654
|
-
*
|
|
1469
|
+
* Sign of a numeric literal with optional unary minus: 1, -1, or 0. Returns
|
|
1470
|
+
* null for anything that isn't a (signed) numeric literal.
|
|
1655
1471
|
*/
|
|
1656
|
-
function numericSign(expr:
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
const inner = numericSign(e.operand)
|
|
1472
|
+
function numericSign(expr: ParsedExpr): number | null {
|
|
1473
|
+
if (expr.kind === 'unary' && expr.op === '-') {
|
|
1474
|
+
const inner = numericSign(expr.argument)
|
|
1660
1475
|
return inner === null ? null : -inner
|
|
1661
1476
|
}
|
|
1662
|
-
if (
|
|
1663
|
-
const n =
|
|
1477
|
+
if (expr.kind === 'literal' && expr.literalType === 'number' && typeof expr.value === 'number') {
|
|
1478
|
+
const n = expr.value
|
|
1664
1479
|
if (Number.isNaN(n)) return null
|
|
1665
1480
|
if (n === 0) return 0
|
|
1666
1481
|
return n > 0 ? 1 : -1
|
|
@@ -1669,21 +1484,18 @@ function numericSign(expr: ts.Expression): number | null {
|
|
|
1669
1484
|
}
|
|
1670
1485
|
|
|
1671
1486
|
/**
|
|
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.
|
|
1487
|
+
* The `whenFalse` arm of a relational ternary is bounded if it's a sign
|
|
1488
|
+
* literal (±1 / 0) or a nested ternary on the same key (the canonical 3-way).
|
|
1676
1489
|
*/
|
|
1677
1490
|
function isBoundedTernaryElse(
|
|
1678
|
-
expr:
|
|
1491
|
+
expr: ParsedExpr,
|
|
1679
1492
|
key: { kind: 'self' } | { kind: 'field'; field: string },
|
|
1680
1493
|
paramA: string,
|
|
1681
1494
|
paramB: string,
|
|
1682
1495
|
): boolean {
|
|
1683
|
-
|
|
1684
|
-
if (
|
|
1685
|
-
|
|
1686
|
-
const nested = classifyTernaryComparator(e, paramA, paramB)
|
|
1496
|
+
if (numericSign(expr) !== null) return true
|
|
1497
|
+
if (expr.kind === 'conditional') {
|
|
1498
|
+
const nested = classifyTernaryComparator(expr, paramA, paramB)
|
|
1687
1499
|
return nested !== null && sortKeyEquals(nested.key, key)
|
|
1688
1500
|
}
|
|
1689
1501
|
return false
|
|
@@ -1698,238 +1510,28 @@ function sortKeyEquals(
|
|
|
1698
1510
|
return true
|
|
1699
1511
|
}
|
|
1700
1512
|
|
|
1513
|
+
/**
|
|
1514
|
+
* Resolve a sort operand to a `self` / `field` key on param A or B. A bare
|
|
1515
|
+
* param identifier is `self`; a single-level non-computed field access on a
|
|
1516
|
+
* param is `field`. Anything deeper returns null.
|
|
1517
|
+
*/
|
|
1701
1518
|
function classifySortOperand(
|
|
1702
|
-
expr:
|
|
1519
|
+
expr: ParsedExpr,
|
|
1703
1520
|
paramA: string,
|
|
1704
1521
|
paramB: string,
|
|
1705
1522
|
): { key: { kind: 'self' } | { kind: 'field'; field: string }; param: 'A' | 'B' } | null {
|
|
1706
|
-
if (
|
|
1707
|
-
if (expr.
|
|
1708
|
-
if (expr.
|
|
1523
|
+
if (expr.kind === 'identifier') {
|
|
1524
|
+
if (expr.name === paramA) return { key: { kind: 'self' }, param: 'A' }
|
|
1525
|
+
if (expr.name === paramB) return { key: { kind: 'self' }, param: 'B' }
|
|
1709
1526
|
return null
|
|
1710
1527
|
}
|
|
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
|
-
}
|
|
1528
|
+
if (expr.kind === 'member' && !expr.computed && expr.object.kind === 'identifier') {
|
|
1529
|
+
if (expr.object.name === paramA) return { key: { kind: 'field', field: expr.property }, param: 'A' }
|
|
1530
|
+
if (expr.object.name === paramB) return { key: { kind: 'field', field: expr.property }, param: 'B' }
|
|
1718
1531
|
}
|
|
1719
1532
|
return null
|
|
1720
1533
|
}
|
|
1721
1534
|
|
|
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
1535
|
|
|
1934
1536
|
/**
|
|
1935
1537
|
* Per-binding entry stored in `fieldMap`: the dotted path from the
|
|
@@ -2070,8 +1672,12 @@ function collectDestructureBindings(
|
|
|
2070
1672
|
let defaultExpr: ParsedExpr | undefined
|
|
2071
1673
|
if (el.initializer) {
|
|
2072
1674
|
const parsed = convertNode(el.initializer, raw)
|
|
2073
|
-
|
|
2074
|
-
|
|
1675
|
+
// An object-literal default isn't lowered into a destructured filter
|
|
1676
|
+
// predicate yet — refuse it exactly as before the kind existed, with
|
|
1677
|
+
// the same reason text the `unsupported` fallback produced (A-1).
|
|
1678
|
+
if (parsed.kind === 'unsupported' || parsed.kind === 'object-literal') {
|
|
1679
|
+
const reason = parsed.kind === 'unsupported' ? parsed.reason : 'Unsupported syntax: ObjectLiteralExpression'
|
|
1680
|
+
return { ok: false, reason: `Default value in destructured filter param failed to parse: ${reason}` }
|
|
2075
1681
|
}
|
|
2076
1682
|
defaultExpr = parsed
|
|
2077
1683
|
}
|
|
@@ -2114,6 +1720,7 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
|
|
|
2114
1720
|
case 'literal':
|
|
2115
1721
|
case 'identifier':
|
|
2116
1722
|
case 'unsupported':
|
|
1723
|
+
case 'object-literal':
|
|
2117
1724
|
return null
|
|
2118
1725
|
case 'member':
|
|
2119
1726
|
return findImpureDefaultNode(expr.object)
|
|
@@ -2144,8 +1751,8 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
|
|
|
2144
1751
|
return null
|
|
2145
1752
|
case 'call':
|
|
2146
1753
|
case 'array-method':
|
|
2147
|
-
case '
|
|
2148
|
-
case '
|
|
1754
|
+
case 'arrow':
|
|
1755
|
+
case 'regex':
|
|
2149
1756
|
return expr.kind
|
|
2150
1757
|
}
|
|
2151
1758
|
}
|
|
@@ -2249,19 +1856,13 @@ function validateRestUsage(
|
|
|
2249
1856
|
if (part.type === 'expression') walk(part.expr)
|
|
2250
1857
|
}
|
|
2251
1858
|
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.
|
|
1859
|
+
case 'arrow':
|
|
1860
|
+
// Inner arrow that re-uses `restName` as one of its own
|
|
1861
|
+
// parameters shadows the outer rest binding — references inside
|
|
1862
|
+
// its body belong to the inner param, not us (#1532 review).
|
|
1863
|
+
if (e.params.includes(restName)) return
|
|
2257
1864
|
walk(e.body)
|
|
2258
1865
|
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
1866
|
case 'array-literal':
|
|
2266
1867
|
for (const el of e.elements) walk(el)
|
|
2267
1868
|
return
|
|
@@ -2271,6 +1872,7 @@ function validateRestUsage(
|
|
|
2271
1872
|
return
|
|
2272
1873
|
case 'literal':
|
|
2273
1874
|
case 'unsupported':
|
|
1875
|
+
case 'object-literal':
|
|
2274
1876
|
return
|
|
2275
1877
|
}
|
|
2276
1878
|
}
|
|
@@ -2370,13 +1972,9 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
|
|
|
2370
1972
|
if (part.type === 'expression') collectIdentifiers(part.expr, out)
|
|
2371
1973
|
}
|
|
2372
1974
|
return
|
|
2373
|
-
case 'arrow
|
|
1975
|
+
case 'arrow':
|
|
2374
1976
|
collectIdentifiers(expr.body, out)
|
|
2375
1977
|
return
|
|
2376
|
-
case 'higher-order':
|
|
2377
|
-
collectIdentifiers(expr.object, out)
|
|
2378
|
-
collectIdentifiers(expr.predicate, out)
|
|
2379
|
-
return
|
|
2380
1978
|
case 'array-literal':
|
|
2381
1979
|
expr.elements.forEach(e => collectIdentifiers(e, out))
|
|
2382
1980
|
return
|
|
@@ -2385,7 +1983,11 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
|
|
|
2385
1983
|
expr.args.forEach(e => collectIdentifiers(e, out))
|
|
2386
1984
|
return
|
|
2387
1985
|
case 'literal':
|
|
1986
|
+
case 'regex':
|
|
2388
1987
|
case 'unsupported':
|
|
1988
|
+
// Mirror `unsupported`: an object literal was not carried before this
|
|
1989
|
+
// kind existed, so it collects no identifiers (byte-identical A-1).
|
|
1990
|
+
case 'object-literal':
|
|
2389
1991
|
return
|
|
2390
1992
|
}
|
|
2391
1993
|
}
|
|
@@ -2463,49 +2065,29 @@ function substituteDestructuredFields(
|
|
|
2463
2065
|
p.type === 'expression' ? { type: 'expression', expr: walk(p.expr) } : p,
|
|
2464
2066
|
),
|
|
2465
2067
|
}
|
|
2466
|
-
case 'arrow
|
|
2068
|
+
case 'arrow':
|
|
2467
2069
|
// 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':
|
|
2070
|
+
// param (a higher-order callback like `.sort(cmp)` / `.filter(p)`
|
|
2071
|
+
// reaches here as the arrow argument of a generic `call`). Leave
|
|
2072
|
+
// its body alone — its comparator/predicate references its own
|
|
2073
|
+
// params, never the enclosing destructure. Skipping the rewrite is
|
|
2074
|
+
// the conservative choice; worst case the adapter emits BF101.
|
|
2479
2075
|
return e
|
|
2480
2076
|
case 'array-literal':
|
|
2481
2077
|
return { kind: 'array-literal', elements: e.elements.map(walk) }
|
|
2482
2078
|
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
2079
|
if (e.method === 'flat') {
|
|
2497
2080
|
// `flatDepth` is a normalised literal — no destructure refs to
|
|
2498
|
-
// substitute. Preserve verbatim
|
|
2081
|
+
// substitute. Preserve verbatim.
|
|
2499
2082
|
return { kind: 'array-method', method: 'flat', object: walk(e.object), args: [], flatDepth: e.flatDepth }
|
|
2500
2083
|
}
|
|
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
2084
|
return { kind: 'array-method', method: e.method, object: walk(e.object), args: e.args.map(walk) }
|
|
2507
2085
|
case 'literal':
|
|
2086
|
+
case 'regex':
|
|
2508
2087
|
case 'unsupported':
|
|
2088
|
+
// Mirror `unsupported`: object literals were not substituted into
|
|
2089
|
+
// before this kind existed — return verbatim (byte-identical A-1).
|
|
2090
|
+
case 'object-literal':
|
|
2509
2091
|
return e
|
|
2510
2092
|
}
|
|
2511
2093
|
}
|
|
@@ -2567,57 +2149,27 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
2567
2149
|
case 'unsupported':
|
|
2568
2150
|
return { supported: false, reason: expr.reason }
|
|
2569
2151
|
|
|
2152
|
+
// A bare object literal is still refused as a standalone template
|
|
2153
|
+
// expression — adapters that lower one as a *value* (Go map / Perl
|
|
2154
|
+
// hashref) do so in their own emitter, like `array-literal`, not
|
|
2155
|
+
// through this support gate. The reason string is the exact text the
|
|
2156
|
+
// `unsupported` fallback produced before the `object-literal` kind
|
|
2157
|
+
// existed, so diagnostics stay byte-identical (Roadmap A-1).
|
|
2158
|
+
case 'object-literal':
|
|
2159
|
+
return { supported: false, reason: 'Unsupported syntax: ObjectLiteralExpression' }
|
|
2160
|
+
|
|
2570
2161
|
case 'identifier':
|
|
2571
2162
|
return { supported: true, level: 'L1' }
|
|
2572
2163
|
|
|
2573
2164
|
case 'literal':
|
|
2574
2165
|
return { supported: true, level: 'L1' }
|
|
2575
2166
|
|
|
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
|
-
}
|
|
2167
|
+
case 'arrow':
|
|
2168
|
+
case 'regex':
|
|
2169
|
+
// Arrow functions / regex literals are only supported as the
|
|
2170
|
+
// argument of a recognised higher-order callback call (handled in
|
|
2171
|
+
// the `call` arm below); they're unsupported standalone.
|
|
2172
|
+
return { supported: false, reason: 'Standalone arrow functions / regex literals are not supported' }
|
|
2621
2173
|
|
|
2622
2174
|
case 'array-literal': {
|
|
2623
2175
|
// Array literal is lowerable iff every element is. Adapters that
|
|
@@ -2633,6 +2185,17 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
2633
2185
|
}
|
|
2634
2186
|
|
|
2635
2187
|
case 'array-method': {
|
|
2188
|
+
// A regex-pattern `.replace` is carried structurally (a `regex` first
|
|
2189
|
+
// arg) but is the deferred form (#1448) — no template language lowers it.
|
|
2190
|
+
// Refuse with the dedicated reason rather than the generic standalone-regex
|
|
2191
|
+
// message, preserving the diagnostic the parser used to emit directly.
|
|
2192
|
+
if (expr.method === 'replace' && expr.args[0]?.kind === 'regex') {
|
|
2193
|
+
return {
|
|
2194
|
+
supported: false,
|
|
2195
|
+
reason:
|
|
2196
|
+
'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 */',
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2636
2199
|
const objSupport = checkSupport(expr.object)
|
|
2637
2200
|
if (!objSupport.supported) return objSupport
|
|
2638
2201
|
for (const arg of expr.args) {
|
|
@@ -2644,6 +2207,36 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
2644
2207
|
|
|
2645
2208
|
|
|
2646
2209
|
case 'call': {
|
|
2210
|
+
// Higher-order callback methods (`.filter`/`.sort`/`.reduce`/… with an
|
|
2211
|
+
// arrow argument) lower via the runtime evaluator (#2018). Supported iff
|
|
2212
|
+
// the receiver and the callback BODY are supported. Recognised before the
|
|
2213
|
+
// `UNSUPPORTED_METHODS` gate so the eval-lowered shapes aren't refused
|
|
2214
|
+
// (a BARE method reference — `arr.filter` uncalled, no arrow arg — still
|
|
2215
|
+
// falls through to the gate). A nested callback inside the body is NOT
|
|
2216
|
+
// refused here: the evaluator refuses it (`serializeParsedExpr` → null)
|
|
2217
|
+
// and each adapter then either lowers it faithfully (Mojo's inline
|
|
2218
|
+
// `grep`, Go's `len (bf_filter_eval …)`) or surfaces BF101 at its
|
|
2219
|
+
// predicate fallback's exact degrade points (#2038) — a blanket refusal
|
|
2220
|
+
// here would break the faithful shapes (#1443 PR4).
|
|
2221
|
+
const cb = asCallbackMethodCall(expr)
|
|
2222
|
+
if (cb) {
|
|
2223
|
+
const objSupport = checkSupport(cb.object)
|
|
2224
|
+
if (!objSupport.supported) return objSupport
|
|
2225
|
+
const bodySupport = checkSupport(cb.arrow.body)
|
|
2226
|
+
if (!bodySupport.supported) {
|
|
2227
|
+
return {
|
|
2228
|
+
supported: false,
|
|
2229
|
+
level: 'L5_UNSUPPORTED',
|
|
2230
|
+
reason: `Higher-order method '.${cb.method}()' with complex callback. ${bodySupport.reason || 'Simplify the callback.'}`,
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
for (const rest of cb.args) {
|
|
2234
|
+
const restSupport = checkSupport(rest)
|
|
2235
|
+
if (!restSupport.supported) return restSupport
|
|
2236
|
+
}
|
|
2237
|
+
return { supported: true, level: 'L5' }
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2647
2240
|
// Check if callee is supported
|
|
2648
2241
|
const calleeSupport = checkSupport(expr.callee)
|
|
2649
2242
|
if (!calleeSupport.supported) {
|
|
@@ -2778,12 +2371,13 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
2778
2371
|
}
|
|
2779
2372
|
|
|
2780
2373
|
/**
|
|
2781
|
-
* Check if expression contains any higher-order method
|
|
2374
|
+
* Check if expression contains any higher-order callback method call
|
|
2375
|
+
* (`.filter`/`.sort`/`.reduce`/… with an arrow argument — see
|
|
2376
|
+
* {@link asCallbackMethodCall}) anywhere in the tree.
|
|
2782
2377
|
*/
|
|
2783
2378
|
export function containsHigherOrder(expr: ParsedExpr): boolean {
|
|
2379
|
+
if (asCallbackMethodCall(expr) !== null) return true
|
|
2784
2380
|
switch (expr.kind) {
|
|
2785
|
-
case 'higher-order':
|
|
2786
|
-
return true
|
|
2787
2381
|
case 'call':
|
|
2788
2382
|
return expr.args.some(containsHigherOrder) || containsHigherOrder(expr.callee)
|
|
2789
2383
|
case 'member':
|
|
@@ -2798,7 +2392,7 @@ export function containsHigherOrder(expr: ParsedExpr): boolean {
|
|
|
2798
2392
|
return containsHigherOrder(expr.left) || containsHigherOrder(expr.right)
|
|
2799
2393
|
case 'conditional':
|
|
2800
2394
|
return containsHigherOrder(expr.test) || containsHigherOrder(expr.consequent) || containsHigherOrder(expr.alternate)
|
|
2801
|
-
case 'arrow
|
|
2395
|
+
case 'arrow':
|
|
2802
2396
|
return containsHigherOrder(expr.body)
|
|
2803
2397
|
case 'array-literal':
|
|
2804
2398
|
return expr.elements.some(containsHigherOrder)
|
|
@@ -2847,6 +2441,29 @@ export function parseBlockBody(
|
|
|
2847
2441
|
return statements
|
|
2848
2442
|
}
|
|
2849
2443
|
|
|
2444
|
+
/**
|
|
2445
|
+
* Like {@link parseBlockBody} but tolerant: a statement `parseStatement` can't
|
|
2446
|
+
* represent is **skipped** rather than failing the whole block. Used to carry a
|
|
2447
|
+
* block-body memo's structure on the IR for adapters that only pattern-match a
|
|
2448
|
+
* recognised prefix of statements (e.g. a `const k = getter(); if (!k) return
|
|
2449
|
+
* CONST` guard) and ignore the rest — including a trailing client-directive
|
|
2450
|
+
* (`@client`) return that the strict parser would reject. Mirrors the tolerant
|
|
2451
|
+
* `continue`-on-unrecognised walks those adapters previously ran over a
|
|
2452
|
+
* re-parsed source string, so it never carries a *more* permissive result.
|
|
2453
|
+
*/
|
|
2454
|
+
export function parseBlockBodyTolerant(
|
|
2455
|
+
block: ts.Block,
|
|
2456
|
+
sourceFile: ts.SourceFile,
|
|
2457
|
+
getJS: (node: ts.Node) => string
|
|
2458
|
+
): ParsedStatement[] {
|
|
2459
|
+
const statements: ParsedStatement[] = []
|
|
2460
|
+
for (const stmt of block.statements) {
|
|
2461
|
+
const parsed = parseStatement(stmt, sourceFile, getJS)
|
|
2462
|
+
if (parsed !== null) statements.push(parsed)
|
|
2463
|
+
}
|
|
2464
|
+
return statements
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2850
2467
|
/**
|
|
2851
2468
|
* Parse a single statement into ParsedStatement.
|
|
2852
2469
|
*/
|
|
@@ -2876,8 +2493,17 @@ function parseStatement(
|
|
|
2876
2493
|
// return; (no value) -> return undefined, treat as return true
|
|
2877
2494
|
return { kind: 'return', value: { kind: 'literal', value: true, literalType: 'boolean' } }
|
|
2878
2495
|
}
|
|
2879
|
-
|
|
2880
|
-
|
|
2496
|
+
// A bare object-literal return (`return { a: 1 }`) re-parses as a *block*
|
|
2497
|
+
// statement if its braces lead the source, yielding `unsupported`. Unwrap
|
|
2498
|
+
// any parens, and when the returned expression is an object literal, wrap
|
|
2499
|
+
// the text in parens to force expression context so it parses as an
|
|
2500
|
+
// `object-literal` ParsedExpr (consumed by the Go object-memo lowering).
|
|
2501
|
+
let retExpr: ts.Expression = stmt.expression
|
|
2502
|
+
while (ts.isParenthesizedExpression(retExpr)) retExpr = retExpr.expression
|
|
2503
|
+
const valueText = getJS(retExpr)
|
|
2504
|
+
const value = parseExpression(
|
|
2505
|
+
ts.isObjectLiteralExpression(retExpr) ? `(${valueText})` : valueText,
|
|
2506
|
+
)
|
|
2881
2507
|
if (value.kind === 'unsupported') {
|
|
2882
2508
|
return null
|
|
2883
2509
|
}
|
|
@@ -2888,7 +2514,7 @@ function parseStatement(
|
|
|
2888
2514
|
if (ts.isIfStatement(stmt)) {
|
|
2889
2515
|
const conditionText = getJS(stmt.expression)
|
|
2890
2516
|
const condition = parseExpression(conditionText)
|
|
2891
|
-
if (condition.kind === 'unsupported') {
|
|
2517
|
+
if (condition.kind === 'unsupported' || condition.kind === 'object-literal') {
|
|
2892
2518
|
return null
|
|
2893
2519
|
}
|
|
2894
2520
|
|
|
@@ -2925,6 +2551,435 @@ function parseStatement(
|
|
|
2925
2551
|
return null
|
|
2926
2552
|
}
|
|
2927
2553
|
|
|
2554
|
+
// =============================================================================
|
|
2555
|
+
// Block → Expression Normalization (#2040)
|
|
2556
|
+
// =============================================================================
|
|
2557
|
+
|
|
2558
|
+
/**
|
|
2559
|
+
* The actionable refusal reason for a block body that is not purely-functionally
|
|
2560
|
+
* expressible. Carried on the `unsupported` ParsedExpr so adapters surface it as
|
|
2561
|
+
* the BF101 message. A loop that mutates a local to accumulate a value is a fold
|
|
2562
|
+
* (already expressible via `.reduce`); a loop that does anything else, a `break`,
|
|
2563
|
+
* a re-assignment, or a side-effecting/I-O call is genuinely imperative and has
|
|
2564
|
+
* no value-position lowering.
|
|
2565
|
+
*/
|
|
2566
|
+
export const IMPERATIVE_BLOCK_REASON =
|
|
2567
|
+
'Block body cannot be normalized to a value expression. Only pure ' +
|
|
2568
|
+
'`const` bindings, value-producing `if` / early `return`, and a final ' +
|
|
2569
|
+
'`return` are supported. Imperative shapes (raw `for` / `while` loops, ' +
|
|
2570
|
+
'`break`, local re-assignment, side-effecting or I/O calls) are not. ' +
|
|
2571
|
+
'Rewrite an accumulation loop as `.reduce(...)`, or move the imperative ' +
|
|
2572
|
+
'body to a `/* @client */` value so it runs natively on the client.'
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* Whether a statement sequence always reaches a `return` on every control-flow
|
|
2576
|
+
* path (so anything textually after it is dead). A bare `return` terminates; an
|
|
2577
|
+
* `if` terminates only when it has an `else` and both branches terminate. Used
|
|
2578
|
+
* by {@link foldBlockToExpr} to decide whether the statements following an `if`
|
|
2579
|
+
* belong to the fall-through (else) path.
|
|
2580
|
+
*/
|
|
2581
|
+
function statementsTerminate(stmts: ParsedStatement[]): boolean {
|
|
2582
|
+
for (const s of stmts) {
|
|
2583
|
+
if (s.kind === 'return') return true
|
|
2584
|
+
if (
|
|
2585
|
+
s.kind === 'if' &&
|
|
2586
|
+
s.alternate !== undefined &&
|
|
2587
|
+
statementsTerminate(s.consequent) &&
|
|
2588
|
+
statementsTerminate(s.alternate)
|
|
2589
|
+
) {
|
|
2590
|
+
return true
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
return false
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
/**
|
|
2597
|
+
* Refusal reason when inlining a `const` binding would capture a free variable
|
|
2598
|
+
* of its initializer under a nested callback parameter of the same name. The
|
|
2599
|
+
* let-inline substitution is not a hygienic (alpha-renaming) substitution, so
|
|
2600
|
+
* rather than silently miscompile the callback we refuse and adapters surface
|
|
2601
|
+
* BF101.
|
|
2602
|
+
*/
|
|
2603
|
+
export const CAPTURE_BLOCK_REASON =
|
|
2604
|
+
'Block body cannot be normalized: inlining a `const` binding would capture ' +
|
|
2605
|
+
'one of its free variables under a nested callback parameter of the same ' +
|
|
2606
|
+
'name (e.g. `const x = a; … list.map(a => a + x)`). Rename the inner ' +
|
|
2607
|
+
'parameter, or move the body to a `/* @client */` value so it runs natively ' +
|
|
2608
|
+
'on the client.'
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* Refusal reason when a `const` initializer may have side effects (it contains
|
|
2612
|
+
* a function/method call) and the binding is NOT used exactly once on a single
|
|
2613
|
+
* runtime path. Let-inline substitutes the initializer at each use site, which
|
|
2614
|
+
* would drop the effect (zero uses) or duplicate it (multiple uses / a use
|
|
2615
|
+
* inside a callback that runs per element) — exactly the side-effecting shape
|
|
2616
|
+
* the fold must refuse rather than miscompile.
|
|
2617
|
+
*/
|
|
2618
|
+
export const IMPURE_INLINE_BLOCK_REASON =
|
|
2619
|
+
'Block body cannot be normalized: a `const` whose initializer may have side ' +
|
|
2620
|
+
'effects (a function or method call) is not used exactly once on every path, ' +
|
|
2621
|
+
'so inlining it would drop the effect on some path or duplicate it on ' +
|
|
2622
|
+
'another. Bind a pure value, use the binding exactly once unconditionally, ' +
|
|
2623
|
+
'or move the body to a `/* @client */` value so it runs natively on the client.'
|
|
2624
|
+
|
|
2625
|
+
/**
|
|
2626
|
+
* Options for {@link foldBlockToExpr}.
|
|
2627
|
+
*/
|
|
2628
|
+
export interface FoldBlockOptions {
|
|
2629
|
+
/**
|
|
2630
|
+
* Names of zero-argument calls that are idempotent reads with no observable
|
|
2631
|
+
* side effect — chiefly reactive getters (signal / memo accessors), which
|
|
2632
|
+
* return the same value each time within a render. Inlining such a read at
|
|
2633
|
+
* multiple sites is evaluation-count-neutral, so the fold may treat
|
|
2634
|
+
* `getter()` as pure. The caller (e.g. `jsx-to-ir`) supplies the set from its
|
|
2635
|
+
* analyzer-collected signal/memo names; callers without that context (the
|
|
2636
|
+
* plain `convertNode` callback path) omit it and every call stays "possibly
|
|
2637
|
+
* impure". A non-empty arg list or a member-call (`a.b()`) is never treated as
|
|
2638
|
+
* pure by this set.
|
|
2639
|
+
*/
|
|
2640
|
+
pureCallNames?: ReadonlySet<string>
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/**
|
|
2644
|
+
* Whether an expression is provably free of side effects, so it is safe to
|
|
2645
|
+
* inline at any number of use sites. Conservative: a function / method call is
|
|
2646
|
+
* possibly impure, EXCEPT a zero-arg call to a name in `pureCallNames` (an
|
|
2647
|
+
* idempotent reactive getter read). Member access is treated as pure, matching
|
|
2648
|
+
* `substituteDestructuredFields` and the rest of the compiler's expression
|
|
2649
|
+
* handling.
|
|
2650
|
+
*/
|
|
2651
|
+
function isPureInit(e: ParsedExpr, pureCallNames?: ReadonlySet<string>): boolean {
|
|
2652
|
+
const pure = (x: ParsedExpr) => isPureInit(x, pureCallNames)
|
|
2653
|
+
switch (e.kind) {
|
|
2654
|
+
case 'identifier':
|
|
2655
|
+
case 'literal':
|
|
2656
|
+
case 'regex':
|
|
2657
|
+
return true
|
|
2658
|
+
case 'member':
|
|
2659
|
+
return pure(e.object)
|
|
2660
|
+
case 'index-access':
|
|
2661
|
+
return pure(e.object) && pure(e.index)
|
|
2662
|
+
case 'binary':
|
|
2663
|
+
case 'logical':
|
|
2664
|
+
return pure(e.left) && pure(e.right)
|
|
2665
|
+
case 'unary':
|
|
2666
|
+
return pure(e.argument)
|
|
2667
|
+
case 'conditional':
|
|
2668
|
+
return pure(e.test) && pure(e.consequent) && pure(e.alternate)
|
|
2669
|
+
case 'template-literal':
|
|
2670
|
+
return e.parts.every(p => p.type !== 'expression' || pure(p.expr))
|
|
2671
|
+
case 'array-literal':
|
|
2672
|
+
return e.elements.every(pure)
|
|
2673
|
+
case 'object-literal':
|
|
2674
|
+
return e.properties.every(p => pure(p.value))
|
|
2675
|
+
case 'call':
|
|
2676
|
+
// A zero-arg reactive getter read (`filter()`, `count()`) is idempotent;
|
|
2677
|
+
// any other call may be effectful or non-deterministic.
|
|
2678
|
+
return (
|
|
2679
|
+
e.callee.kind === 'identifier' &&
|
|
2680
|
+
e.args.length === 0 &&
|
|
2681
|
+
pureCallNames !== undefined &&
|
|
2682
|
+
pureCallNames.has(e.callee.name)
|
|
2683
|
+
)
|
|
2684
|
+
// A method call may be effectful; an arrow value can capture impurity;
|
|
2685
|
+
// `unsupported` is opaque. Treat all as possibly impure.
|
|
2686
|
+
case 'array-method':
|
|
2687
|
+
case 'arrow':
|
|
2688
|
+
case 'unsupported':
|
|
2689
|
+
return false
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
/**
|
|
2694
|
+
* The `{ min, max }` number of times `name` is evaluated on a single runtime
|
|
2695
|
+
* path through `expr` — the minimum-cost path and the maximum-cost path. Used to
|
|
2696
|
+
* decide whether inlining a possibly-impure init is evaluation-count-preserving:
|
|
2697
|
+
* sound only when it is evaluated **exactly once on every path** (`min === 1 &&
|
|
2698
|
+
* max === 1`), so the substituted call neither drops nor duplicates its effect.
|
|
2699
|
+
*
|
|
2700
|
+
* Path semantics:
|
|
2701
|
+
* - `conditional` evaluates the test then exactly one arm → test + the min/max
|
|
2702
|
+
* of the two arms (a binding used in only one arm has `min` 0: it is skipped
|
|
2703
|
+
* on the other path).
|
|
2704
|
+
* - `logical` (`&&` / `||` / `??`) evaluates the left, then the right only on
|
|
2705
|
+
* some paths (short-circuit) → the right contributes to `max` but not `min`.
|
|
2706
|
+
* - a nested `arrow` body may run any number of times (a callback invoked per
|
|
2707
|
+
* element / comparison, or never) → a use inside has `min` 0 and `max`
|
|
2708
|
+
* `Infinity`, forcing an impure binding referenced from a callback to be
|
|
2709
|
+
* refused.
|
|
2710
|
+
*/
|
|
2711
|
+
function usesPerPath(name: string, expr: ParsedExpr): { min: number; max: number } {
|
|
2712
|
+
const add = (a: { min: number; max: number }, b: { min: number; max: number }) => ({
|
|
2713
|
+
min: a.min + b.min,
|
|
2714
|
+
max: a.max + b.max,
|
|
2715
|
+
})
|
|
2716
|
+
const sum = (xs: ParsedExpr[]) => xs.reduce((acc, x) => add(acc, walk(x)), { min: 0, max: 0 })
|
|
2717
|
+
const walk = (e: ParsedExpr): { min: number; max: number } => {
|
|
2718
|
+
switch (e.kind) {
|
|
2719
|
+
case 'identifier':
|
|
2720
|
+
return e.name === name ? { min: 1, max: 1 } : { min: 0, max: 0 }
|
|
2721
|
+
case 'literal':
|
|
2722
|
+
case 'regex':
|
|
2723
|
+
case 'unsupported':
|
|
2724
|
+
return { min: 0, max: 0 }
|
|
2725
|
+
case 'member':
|
|
2726
|
+
return walk(e.object)
|
|
2727
|
+
case 'index-access':
|
|
2728
|
+
return add(walk(e.object), walk(e.index))
|
|
2729
|
+
case 'binary':
|
|
2730
|
+
return add(walk(e.left), walk(e.right))
|
|
2731
|
+
case 'logical': {
|
|
2732
|
+
// The right operand is only evaluated on some paths (short-circuit), so
|
|
2733
|
+
// it contributes to the max but never to the guaranteed min.
|
|
2734
|
+
const l = walk(e.left)
|
|
2735
|
+
const r = walk(e.right)
|
|
2736
|
+
return { min: l.min, max: l.max + r.max }
|
|
2737
|
+
}
|
|
2738
|
+
case 'unary':
|
|
2739
|
+
return walk(e.argument)
|
|
2740
|
+
case 'conditional': {
|
|
2741
|
+
const t = walk(e.test)
|
|
2742
|
+
const c = walk(e.consequent)
|
|
2743
|
+
const a = walk(e.alternate)
|
|
2744
|
+
return { min: t.min + Math.min(c.min, a.min), max: t.max + Math.max(c.max, a.max) }
|
|
2745
|
+
}
|
|
2746
|
+
case 'template-literal':
|
|
2747
|
+
return sum(e.parts.flatMap(p => (p.type === 'expression' ? [p.expr] : [])))
|
|
2748
|
+
case 'call':
|
|
2749
|
+
return add(walk(e.callee), sum(e.args))
|
|
2750
|
+
case 'array-literal':
|
|
2751
|
+
return sum(e.elements)
|
|
2752
|
+
case 'array-method':
|
|
2753
|
+
return add(walk(e.object), e.method === 'flat' ? { min: 0, max: 0 } : sum(e.args))
|
|
2754
|
+
case 'object-literal':
|
|
2755
|
+
return sum(e.properties.map(p => p.value))
|
|
2756
|
+
case 'arrow':
|
|
2757
|
+
// A callback body may run any number of times (per element, or never).
|
|
2758
|
+
return walk(e.body).max > 0 ? { min: 0, max: Number.POSITIVE_INFINITY } : { min: 0, max: 0 }
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
return walk(expr)
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* Inline `name → value` everywhere it appears free in `expr` (the let-inline
|
|
2766
|
+
* step). Returns `null` if the substitution would capture a free variable of
|
|
2767
|
+
* `value` under a nested callback parameter of the same name — that shape is
|
|
2768
|
+
* unsound to inline non-hygienically, so the caller refuses with
|
|
2769
|
+
* {@link CAPTURE_BLOCK_REASON}. A nested arrow parameter that shadows `name`
|
|
2770
|
+
* leaves that inner reference untouched (it is the parameter, not the binding).
|
|
2771
|
+
* Mirrors the structural walk of `substituteDestructuredFields`; every
|
|
2772
|
+
* `ParsedExpr` kind is handled so a new kind surfaces as a compile error here.
|
|
2773
|
+
*/
|
|
2774
|
+
function inlineBinding(
|
|
2775
|
+
expr: ParsedExpr,
|
|
2776
|
+
name: string,
|
|
2777
|
+
value: ParsedExpr,
|
|
2778
|
+
): ParsedExpr | null {
|
|
2779
|
+
// Free variables of `value` that an enclosing callback parameter could capture.
|
|
2780
|
+
const valueFree = new Set<string>()
|
|
2781
|
+
collectIdentifiers(value, valueFree)
|
|
2782
|
+
let captured = false
|
|
2783
|
+
|
|
2784
|
+
const walk = (e: ParsedExpr, enclosing: ReadonlySet<string>): ParsedExpr => {
|
|
2785
|
+
switch (e.kind) {
|
|
2786
|
+
case 'identifier': {
|
|
2787
|
+
if (e.name !== name) return e
|
|
2788
|
+
// Shadowed by an enclosing callback param → this is the parameter, not
|
|
2789
|
+
// the binding; leave it.
|
|
2790
|
+
if (enclosing.has(e.name)) return e
|
|
2791
|
+
// Inlining here: does any enclosing param capture a free var of `value`?
|
|
2792
|
+
for (const p of enclosing) {
|
|
2793
|
+
if (valueFree.has(p)) {
|
|
2794
|
+
captured = true
|
|
2795
|
+
return e
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
return value
|
|
2799
|
+
}
|
|
2800
|
+
case 'call':
|
|
2801
|
+
return { kind: 'call', callee: walk(e.callee, enclosing), args: e.args.map(a => walk(a, enclosing)) }
|
|
2802
|
+
case 'member':
|
|
2803
|
+
return { kind: 'member', object: walk(e.object, enclosing), property: e.property, computed: e.computed }
|
|
2804
|
+
case 'index-access':
|
|
2805
|
+
return { kind: 'index-access', object: walk(e.object, enclosing), index: walk(e.index, enclosing) }
|
|
2806
|
+
case 'binary':
|
|
2807
|
+
return { kind: 'binary', op: e.op, left: walk(e.left, enclosing), right: walk(e.right, enclosing) }
|
|
2808
|
+
case 'logical':
|
|
2809
|
+
return { kind: 'logical', op: e.op, left: walk(e.left, enclosing), right: walk(e.right, enclosing) }
|
|
2810
|
+
case 'unary':
|
|
2811
|
+
return { kind: 'unary', op: e.op, argument: walk(e.argument, enclosing) }
|
|
2812
|
+
case 'conditional':
|
|
2813
|
+
return { kind: 'conditional', test: walk(e.test, enclosing), consequent: walk(e.consequent, enclosing), alternate: walk(e.alternate, enclosing) }
|
|
2814
|
+
case 'template-literal':
|
|
2815
|
+
return {
|
|
2816
|
+
kind: 'template-literal',
|
|
2817
|
+
parts: e.parts.map(p =>
|
|
2818
|
+
p.type === 'expression' ? { type: 'expression', expr: walk(p.expr, enclosing) } : p,
|
|
2819
|
+
),
|
|
2820
|
+
}
|
|
2821
|
+
case 'arrow': {
|
|
2822
|
+
const innerEnclosing = e.params.length === 0 ? enclosing : new Set([...enclosing, ...e.params])
|
|
2823
|
+
return { kind: 'arrow', params: e.params, body: walk(e.body, innerEnclosing) }
|
|
2824
|
+
}
|
|
2825
|
+
case 'array-literal':
|
|
2826
|
+
return { kind: 'array-literal', elements: e.elements.map(el => walk(el, enclosing)) }
|
|
2827
|
+
case 'array-method':
|
|
2828
|
+
if (e.method === 'flat') {
|
|
2829
|
+
return { kind: 'array-method', method: 'flat', object: walk(e.object, enclosing), args: [], flatDepth: e.flatDepth }
|
|
2830
|
+
}
|
|
2831
|
+
return { kind: 'array-method', method: e.method, object: walk(e.object, enclosing), args: e.args.map(a => walk(a, enclosing)) }
|
|
2832
|
+
case 'object-literal':
|
|
2833
|
+
return {
|
|
2834
|
+
kind: 'object-literal',
|
|
2835
|
+
properties: e.properties.map(p => ({ ...p, value: walk(p.value, enclosing) })),
|
|
2836
|
+
raw: e.raw,
|
|
2837
|
+
}
|
|
2838
|
+
case 'literal':
|
|
2839
|
+
case 'regex':
|
|
2840
|
+
case 'unsupported':
|
|
2841
|
+
return e
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
const result = walk(expr, new Set())
|
|
2846
|
+
return captured ? null : result
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
/**
|
|
2850
|
+
* Fold a value-producing block body — a {@link ParsedStatement} sequence of
|
|
2851
|
+
* `const` bindings, value-producing `if` / early `return`, and a terminal
|
|
2852
|
+
* `return` — into a single {@link ParsedExpr}, so block-bodied memos / derived /
|
|
2853
|
+
* callbacks flow through the same expression surface as expression-bodied ones
|
|
2854
|
+
* (#2040, carved from #2018 stage 5). This generalizes the per-idiom block-memo
|
|
2855
|
+
* recognizers (#1897 / #1945 / #2015) the same way the evaluator replaced the
|
|
2856
|
+
* `bf_sort` / `bf_reduce` catalogue: one normalization, no growing pattern list.
|
|
2857
|
+
*
|
|
2858
|
+
* Transformations:
|
|
2859
|
+
* - `const x = <init>; …` → inline `x`'s init into the rest (let-inline).
|
|
2860
|
+
* - `if (c) <then> [else <else>] …` → `c ? fold(then-path) : fold(else-path)`,
|
|
2861
|
+
* where a branch that does not itself terminate continues into the
|
|
2862
|
+
* statements following the `if` (the early-return idiom).
|
|
2863
|
+
* - `return <v>` → `<v>`.
|
|
2864
|
+
*
|
|
2865
|
+
* The rest is folded first, leaving each binding as a free identifier, so its
|
|
2866
|
+
* use count can be measured before inlining. Inlining is refused (→ `ok: false`)
|
|
2867
|
+
* when it would be unsound:
|
|
2868
|
+
* - a possibly-impure init (one containing a call) used zero or more than once
|
|
2869
|
+
* on a path would drop or duplicate the side effect (a pure init is always
|
|
2870
|
+
* safe to inline any number of times);
|
|
2871
|
+
* - a substitution would capture a free variable of the init under a nested
|
|
2872
|
+
* callback parameter of the same name (substitution is not hygienic).
|
|
2873
|
+
*
|
|
2874
|
+
* Returns `{ ok: false }` for a sequence that cannot produce a value on some
|
|
2875
|
+
* path (falls through with no `return`) — the genuinely-imperative residue that
|
|
2876
|
+
* {@link IMPERATIVE_BLOCK_REASON} describes. The input is assumed to be the
|
|
2877
|
+
* STRICT parse ({@link parseBlockBody}, not the tolerant variant): every source
|
|
2878
|
+
* statement is represented, so a `false` here reflects the real shape rather
|
|
2879
|
+
* than a silently-dropped statement.
|
|
2880
|
+
*/
|
|
2881
|
+
export function foldBlockToExpr(
|
|
2882
|
+
stmts: ParsedStatement[],
|
|
2883
|
+
opts?: FoldBlockOptions,
|
|
2884
|
+
): { ok: true; expr: ParsedExpr } | { ok: false; reason: string } {
|
|
2885
|
+
if (stmts.length === 0) {
|
|
2886
|
+
return { ok: false, reason: IMPERATIVE_BLOCK_REASON }
|
|
2887
|
+
}
|
|
2888
|
+
const [head, ...rest] = stmts
|
|
2889
|
+
switch (head.kind) {
|
|
2890
|
+
case 'var-decl': {
|
|
2891
|
+
// Fold the remaining statements first, leaving `head.name` free so its use
|
|
2892
|
+
// count can drive the soundness check. Any earlier-binding references in
|
|
2893
|
+
// the rest are inlined by the enclosing `var-decl` frames; references to
|
|
2894
|
+
// `head.name` inside `head.init` cannot occur (a `const` can't read itself).
|
|
2895
|
+
const restFold = foldBlockToExpr(rest, opts)
|
|
2896
|
+
if (!restFold.ok) return restFold
|
|
2897
|
+
const uses = usesPerPath(head.name, restFold.expr)
|
|
2898
|
+
// A possibly-impure init is only safe to inline when it is evaluated
|
|
2899
|
+
// exactly once on EVERY path — same as the original block, which runs the
|
|
2900
|
+
// `const` initializer unconditionally once. `min !== 1` catches an effect
|
|
2901
|
+
// dropped on some path (unused, or used in only one ternary arm / a
|
|
2902
|
+
// short-circuited operand / a callback); `max !== 1` catches duplication.
|
|
2903
|
+
// A pure init is safe at any count (drop / duplicate is unobservable);
|
|
2904
|
+
// idempotent reactive getter reads in `pureCallNames` count as pure.
|
|
2905
|
+
if (!isPureInit(head.init, opts?.pureCallNames) && !(uses.min === 1 && uses.max === 1)) {
|
|
2906
|
+
return { ok: false, reason: IMPURE_INLINE_BLOCK_REASON }
|
|
2907
|
+
}
|
|
2908
|
+
const inlined = inlineBinding(restFold.expr, head.name, head.init)
|
|
2909
|
+
if (inlined === null) {
|
|
2910
|
+
return { ok: false, reason: CAPTURE_BLOCK_REASON }
|
|
2911
|
+
}
|
|
2912
|
+
return { ok: true, expr: inlined }
|
|
2913
|
+
}
|
|
2914
|
+
case 'return':
|
|
2915
|
+
return { ok: true, expr: head.value }
|
|
2916
|
+
case 'if': {
|
|
2917
|
+
// A branch that doesn't return falls through to the statements after the
|
|
2918
|
+
// `if` (early-return idiom). A branch that returns makes `rest` dead for
|
|
2919
|
+
// that path, so it is not appended. `rest` is intentionally duplicated
|
|
2920
|
+
// into both fall-through paths; because each path is a separate ternary
|
|
2921
|
+
// arm, a binding used once per arm is still evaluated at most once per
|
|
2922
|
+
// runtime path.
|
|
2923
|
+
const thenPath = statementsTerminate(head.consequent)
|
|
2924
|
+
? head.consequent
|
|
2925
|
+
: [...head.consequent, ...rest]
|
|
2926
|
+
const elseBase = head.alternate ?? []
|
|
2927
|
+
const elsePath = statementsTerminate(elseBase)
|
|
2928
|
+
? elseBase
|
|
2929
|
+
: [...elseBase, ...rest]
|
|
2930
|
+
const consequent = foldBlockToExpr(thenPath, opts)
|
|
2931
|
+
if (!consequent.ok) return consequent
|
|
2932
|
+
const alternate = foldBlockToExpr(elsePath, opts)
|
|
2933
|
+
if (!alternate.ok) return alternate
|
|
2934
|
+
return {
|
|
2935
|
+
ok: true,
|
|
2936
|
+
expr: {
|
|
2937
|
+
kind: 'conditional',
|
|
2938
|
+
test: head.condition,
|
|
2939
|
+
consequent: consequent.expr,
|
|
2940
|
+
alternate: alternate.expr,
|
|
2941
|
+
},
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
/**
|
|
2948
|
+
* Rewrite a ternary whose arms are used in BOOLEAN context — e.g. the result of
|
|
2949
|
+
* folding a block-bodied filter predicate (`if (c) return A; return B` →
|
|
2950
|
+
* `c ? A : B`) — into an equivalent `&&` / `||` expression, so it flows through
|
|
2951
|
+
* the ordinary boolean-expression lowering instead of needing a dedicated
|
|
2952
|
+
* block-condition renderer per adapter (#2040). Boolean-literal arms collapse:
|
|
2953
|
+
*
|
|
2954
|
+
* c ? true : false → c
|
|
2955
|
+
* c ? true : f → c || f
|
|
2956
|
+
* c ? t : false → c && t
|
|
2957
|
+
* c ? false : f → !c && f
|
|
2958
|
+
* c ? t : true → !c || t
|
|
2959
|
+
* c ? t : f → (c && t) || (!c && f)
|
|
2960
|
+
*
|
|
2961
|
+
* Arms are flattened recursively (an `else if` chain is a nested ternary); the
|
|
2962
|
+
* test is left as-is. Only valid where the consumer interprets the value as a
|
|
2963
|
+
* boolean (a filter predicate). Non-conditional input is returned unchanged.
|
|
2964
|
+
*/
|
|
2965
|
+
export function predicateTernaryToLogical(expr: ParsedExpr): ParsedExpr {
|
|
2966
|
+
if (expr.kind !== 'conditional') return expr
|
|
2967
|
+
const cond = expr.test
|
|
2968
|
+
const t = predicateTernaryToLogical(expr.consequent)
|
|
2969
|
+
const f = predicateTernaryToLogical(expr.alternate)
|
|
2970
|
+
const isTrue = (x: ParsedExpr) => x.kind === 'literal' && x.literalType === 'boolean' && x.value === true
|
|
2971
|
+
const isFalse = (x: ParsedExpr) => x.kind === 'literal' && x.literalType === 'boolean' && x.value === false
|
|
2972
|
+
const not = (x: ParsedExpr): ParsedExpr => ({ kind: 'unary', op: '!', argument: x })
|
|
2973
|
+
const and = (a: ParsedExpr, b: ParsedExpr): ParsedExpr => ({ kind: 'logical', op: '&&', left: a, right: b })
|
|
2974
|
+
const or = (a: ParsedExpr, b: ParsedExpr): ParsedExpr => ({ kind: 'logical', op: '||', left: a, right: b })
|
|
2975
|
+
if (isTrue(t) && isFalse(f)) return cond
|
|
2976
|
+
if (isTrue(t)) return or(cond, f)
|
|
2977
|
+
if (isFalse(f)) return and(cond, t)
|
|
2978
|
+
if (isFalse(t)) return and(not(cond), f)
|
|
2979
|
+
if (isTrue(f)) return or(not(cond), t)
|
|
2980
|
+
return or(and(cond, t), and(not(cond), f))
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2928
2983
|
/**
|
|
2929
2984
|
* Parse an if branch (then or else) into ParsedStatement array.
|
|
2930
2985
|
*/
|
|
@@ -2975,28 +3030,17 @@ export function exprToString(expr: ParsedExpr): string {
|
|
|
2975
3030
|
return '`' + expr.parts.map(p =>
|
|
2976
3031
|
p.type === 'string' ? p.value : `\${${exprToString(p.expr)}}`
|
|
2977
3032
|
).join('') + '`'
|
|
2978
|
-
case 'arrow
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
3033
|
+
case 'arrow': {
|
|
3034
|
+
// Single-param arrows round-trip without parens (`x => …`); multi-param
|
|
3035
|
+
// need them (`(a, b) => …`).
|
|
3036
|
+
const params = expr.params.length === 1 ? expr.params[0] : `(${expr.params.join(', ')})`
|
|
3037
|
+
return `${params} => ${exprToString(expr.body)}`
|
|
3038
|
+
}
|
|
3039
|
+
case 'regex':
|
|
3040
|
+
return expr.raw
|
|
2982
3041
|
case 'array-literal':
|
|
2983
3042
|
return `[${expr.elements.map(exprToString).join(', ')}]`
|
|
2984
3043
|
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
3044
|
if (expr.method === 'flat') {
|
|
3001
3045
|
// Preserve the normalised depth so diagnostics don't misleadingly
|
|
3002
3046
|
// print `.flat()` for a `.flat(2)` / `.flat(Infinity)` source.
|
|
@@ -3004,12 +3048,11 @@ export function exprToString(expr: ParsedExpr): string {
|
|
|
3004
3048
|
const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
|
|
3005
3049
|
return `${exprToString(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
|
|
3006
3050
|
}
|
|
3007
|
-
if (expr.method === 'flatMap') {
|
|
3008
|
-
const { param, raw } = expr.flatMapOp
|
|
3009
|
-
return `${exprToString(expr.object)}.flatMap(${param} => ${raw})`
|
|
3010
|
-
}
|
|
3011
3051
|
return `${exprToString(expr.object)}.${expr.method}(${expr.args.map(exprToString).join(', ')})`
|
|
3012
3052
|
case 'unsupported':
|
|
3053
|
+
// `raw` holds the original expression string (same value the old
|
|
3054
|
+
// `unsupported` carried), so the round-trip stays byte-identical.
|
|
3055
|
+
case 'object-literal':
|
|
3013
3056
|
return `[UNSUPPORTED: ${expr.raw}]`
|
|
3014
3057
|
}
|
|
3015
3058
|
}
|
|
@@ -3058,30 +3101,19 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
|
|
|
3058
3101
|
return '`' + expr.parts.map(p =>
|
|
3059
3102
|
p.type === 'string' ? p.value : `\${${stringifyParsedExpr(p.expr)}}`
|
|
3060
3103
|
).join('') + '`'
|
|
3061
|
-
case 'arrow
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3104
|
+
case 'arrow': {
|
|
3105
|
+
// Round-trip to valid JS for the CSR / Hono path and downstream
|
|
3106
|
+
// re-parsers. A higher-order callback (`.sort`/`.filter`/…) reaches here
|
|
3107
|
+
// as the arrow argument of a generic `call`. Single-param arrows round-
|
|
3108
|
+
// trip without parens (`x => …`); multi-param need them (`(a, b) => …`).
|
|
3109
|
+
const params = expr.params.length === 1 ? expr.params[0] : `(${expr.params.join(', ')})`
|
|
3110
|
+
return `${params} => ${stringifyParsedExpr(expr.body)}`
|
|
3111
|
+
}
|
|
3112
|
+
case 'regex':
|
|
3113
|
+
return expr.raw
|
|
3065
3114
|
case 'array-literal':
|
|
3066
3115
|
return `[${expr.elements.map(stringifyParsedExpr).join(', ')}]`
|
|
3067
3116
|
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
3117
|
if (expr.method === 'flat') {
|
|
3086
3118
|
// Round-trip the normalised depth back to JS for the CSR / Hono
|
|
3087
3119
|
// path: `'infinity'` → `Infinity`, `1` is left implicit (`.flat()`).
|
|
@@ -3089,18 +3121,432 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
|
|
|
3089
3121
|
const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
|
|
3090
3122
|
return `${stringifyParsedExpr(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
|
|
3091
3123
|
}
|
|
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
3124
|
return `${stringifyParsedExpr(expr.object)}.${expr.method}(${expr.args.map(stringifyParsedExpr).join(', ')})`
|
|
3099
3125
|
case 'unsupported':
|
|
3126
|
+
// `raw` is the original expression string, so re-stringification is
|
|
3127
|
+
// byte-identical to the pre-`object-literal` behaviour (Roadmap A-1).
|
|
3128
|
+
case 'object-literal':
|
|
3100
3129
|
return expr.raw
|
|
3101
3130
|
}
|
|
3102
3131
|
}
|
|
3103
3132
|
|
|
3133
|
+
/**
|
|
3134
|
+
* Rewrite every zero-arg `call` node whose callee is a bare identifier in
|
|
3135
|
+
* `names` into that identifier — `tag()` → `tag` — leaving everything else
|
|
3136
|
+
* untouched. Returns a new tree; the input is never mutated.
|
|
3137
|
+
*
|
|
3138
|
+
* Rationale: in an SSR seed/constructor context a signal/memo getter call
|
|
3139
|
+
* reads the already-computed SEEDED value, so `tag()` reduces to "the value
|
|
3140
|
+
* bound to `tag`". Materialising the call lets the runtime evaluator (which
|
|
3141
|
+
* refuses any non-builtin call, {@link toEvalNode}'s `evalBuiltinCalleeName`
|
|
3142
|
+
* gate) evaluate a predicate that reads sibling memos — e.g. a `.filter`
|
|
3143
|
+
* predicate `(p) => !tag() || p.tags.includes(tag())` — with the getter's
|
|
3144
|
+
* value supplied through the evaluator's `base_env` instead of an
|
|
3145
|
+
* unsupported call node. `names` is caller-supplied (typically the sibling
|
|
3146
|
+
* signals/memos seeded alongside the derived memo being lowered), so a call
|
|
3147
|
+
* to an unrelated function is left as a `call` node and still refused by the
|
|
3148
|
+
* evaluator's builtin gate if it reaches `serializeParsedExpr`.
|
|
3149
|
+
*/
|
|
3150
|
+
export function materializeGetterCalls(expr: ParsedExpr, names: ReadonlySet<string>): ParsedExpr {
|
|
3151
|
+
const rw = (e: ParsedExpr): ParsedExpr => materializeGetterCalls(e, names)
|
|
3152
|
+
switch (expr.kind) {
|
|
3153
|
+
case 'call':
|
|
3154
|
+
if (
|
|
3155
|
+
expr.args.length === 0 &&
|
|
3156
|
+
expr.callee.kind === 'identifier' &&
|
|
3157
|
+
names.has(expr.callee.name)
|
|
3158
|
+
) {
|
|
3159
|
+
return { kind: 'identifier', name: expr.callee.name }
|
|
3160
|
+
}
|
|
3161
|
+
return { kind: 'call', callee: rw(expr.callee), args: expr.args.map(rw) }
|
|
3162
|
+
case 'binary':
|
|
3163
|
+
return { kind: 'binary', op: expr.op, left: rw(expr.left), right: rw(expr.right) }
|
|
3164
|
+
case 'logical':
|
|
3165
|
+
return { kind: 'logical', op: expr.op, left: rw(expr.left), right: rw(expr.right) }
|
|
3166
|
+
case 'unary':
|
|
3167
|
+
return { kind: 'unary', op: expr.op, argument: rw(expr.argument) }
|
|
3168
|
+
case 'conditional':
|
|
3169
|
+
return {
|
|
3170
|
+
kind: 'conditional',
|
|
3171
|
+
test: rw(expr.test),
|
|
3172
|
+
consequent: rw(expr.consequent),
|
|
3173
|
+
alternate: rw(expr.alternate),
|
|
3174
|
+
}
|
|
3175
|
+
case 'member':
|
|
3176
|
+
return { kind: 'member', object: rw(expr.object), property: expr.property, computed: expr.computed }
|
|
3177
|
+
case 'index-access':
|
|
3178
|
+
return { kind: 'index-access', object: rw(expr.object), index: rw(expr.index) }
|
|
3179
|
+
case 'template-literal':
|
|
3180
|
+
return {
|
|
3181
|
+
kind: 'template-literal',
|
|
3182
|
+
parts: expr.parts.map(p => (p.type === 'string' ? p : { type: 'expression', expr: rw(p.expr) })),
|
|
3183
|
+
}
|
|
3184
|
+
case 'array-literal':
|
|
3185
|
+
return { kind: 'array-literal', elements: expr.elements.map(rw) }
|
|
3186
|
+
case 'array-method':
|
|
3187
|
+
// `flat`'s `args` is always `[]` (the depth is carried structurally in
|
|
3188
|
+
// `flatDepth`, not `args`) — still rewrite `object`, just skip the
|
|
3189
|
+
// `args.map` that every other method needs.
|
|
3190
|
+
if (expr.method === 'flat') return { ...expr, object: rw(expr.object) }
|
|
3191
|
+
return { ...expr, object: rw(expr.object), args: expr.args.map(rw) }
|
|
3192
|
+
case 'object-literal':
|
|
3193
|
+
return {
|
|
3194
|
+
kind: 'object-literal',
|
|
3195
|
+
raw: expr.raw,
|
|
3196
|
+
properties: expr.properties.map(p => ({ ...p, value: rw(p.value) })),
|
|
3197
|
+
}
|
|
3198
|
+
case 'arrow':
|
|
3199
|
+
return { kind: 'arrow', params: expr.params, body: rw(expr.body) }
|
|
3200
|
+
// Leaves / opaque shapes — nothing to rewrite.
|
|
3201
|
+
case 'identifier':
|
|
3202
|
+
case 'literal':
|
|
3203
|
+
case 'regex':
|
|
3204
|
+
case 'unsupported':
|
|
3205
|
+
return expr
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
/**
|
|
3210
|
+
* Serialize a pure-expression `ParsedExpr` (a higher-order callback body) into
|
|
3211
|
+
* the minimal JSON the runtime evaluator consumes — the format pinned by the
|
|
3212
|
+
* `eval-vectors` golden cases and read by Go `eval.go` `EvalNode` / Perl
|
|
3213
|
+
* `Evaluator.pm` `evaluate`. Only the evaluator-recognized fields are emitted
|
|
3214
|
+
* per kind (a literal carries just `value`; `literalType` / `raw` are dropped —
|
|
3215
|
+
* the evaluator never reads them; `member.computed` is kept when set so a
|
|
3216
|
+
* computed member stays distinguishable), keeping the embedded body blob small
|
|
3217
|
+
* and stable.
|
|
3218
|
+
*
|
|
3219
|
+
* Returns `null` when the tree contains a shape outside the evaluator's surface
|
|
3220
|
+
* — a folded `higher-order` / `array-method`, an `arrow-fn`, an `unsupported`
|
|
3221
|
+
* node, an operator the evaluator doesn't implement, or a `call` whose callee
|
|
3222
|
+
* isn't an allowlisted builtin (`Math.*` / `String` / `Number` / `Boolean`) — so
|
|
3223
|
+
* the caller refuses the body (BF101 / `@client`) instead of emitting a blob the
|
|
3224
|
+
* evaluator would read as nil. The evaluator's support criterion is
|
|
3225
|
+
* purely-functional expressibility; this is its compile-time gate. (#2018)
|
|
3226
|
+
*/
|
|
3227
|
+
export function serializeParsedExpr(expr: ParsedExpr): string | null {
|
|
3228
|
+
const node = toEvalNode(expr)
|
|
3229
|
+
return node === null ? null : JSON.stringify(node)
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
/**
|
|
3233
|
+
* The free variables a higher-order callback body references — every bare
|
|
3234
|
+
* identifier in a value position, minus the callback's own `params`. The
|
|
3235
|
+
* adapter materializes each into the evaluator's `base_env` (mapping the JS name
|
|
3236
|
+
* to its SSR value). Walks exactly the value positions {@link serializeParsedExpr}
|
|
3237
|
+
* serializes (so it sees object-literal *values* and template-expression parts,
|
|
3238
|
+
* and skips member property names / object keys, which are not references).
|
|
3239
|
+
* Returns a sorted, de-duplicated list for stable emit. (#2018)
|
|
3240
|
+
*/
|
|
3241
|
+
export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): string[] {
|
|
3242
|
+
const found = new Set<string>()
|
|
3243
|
+
const visit = (e: ParsedExpr): void => {
|
|
3244
|
+
switch (e.kind) {
|
|
3245
|
+
case 'identifier':
|
|
3246
|
+
if (!params.has(e.name)) found.add(e.name)
|
|
3247
|
+
return
|
|
3248
|
+
case 'binary':
|
|
3249
|
+
case 'logical':
|
|
3250
|
+
visit(e.left)
|
|
3251
|
+
visit(e.right)
|
|
3252
|
+
return
|
|
3253
|
+
case 'unary':
|
|
3254
|
+
visit(e.argument)
|
|
3255
|
+
return
|
|
3256
|
+
case 'conditional':
|
|
3257
|
+
visit(e.test)
|
|
3258
|
+
visit(e.consequent)
|
|
3259
|
+
visit(e.alternate)
|
|
3260
|
+
return
|
|
3261
|
+
case 'member':
|
|
3262
|
+
visit(e.object)
|
|
3263
|
+
return
|
|
3264
|
+
case 'index-access':
|
|
3265
|
+
visit(e.object)
|
|
3266
|
+
visit(e.index)
|
|
3267
|
+
return
|
|
3268
|
+
case 'call':
|
|
3269
|
+
// A builtin callee (`String`/`Number`/`Boolean`, or `Math.<fn>`) is
|
|
3270
|
+
// resolved syntactically by the evaluator — its identifier is NOT a
|
|
3271
|
+
// captured free var. Visiting it would add `Math` / `String` to the
|
|
3272
|
+
// env, making the adapter emit an undefined `$Math` / `.Math` base_env
|
|
3273
|
+
// entry (Copilot review #2031). Skip the callee identifier in that
|
|
3274
|
+
// case; the arguments are still real references and are visited.
|
|
3275
|
+
if (evalBuiltinCalleeName(e.callee) === null) visit(e.callee)
|
|
3276
|
+
e.args.forEach(visit)
|
|
3277
|
+
return
|
|
3278
|
+
case 'template-literal':
|
|
3279
|
+
for (const p of e.parts) if (p.type === 'expression') visit(p.expr)
|
|
3280
|
+
return
|
|
3281
|
+
case 'array-literal':
|
|
3282
|
+
e.elements.forEach(visit)
|
|
3283
|
+
return
|
|
3284
|
+
case 'object-literal':
|
|
3285
|
+
// Object *values* are references; keys are not. (Shorthand `{ x }`
|
|
3286
|
+
// carries the ref on its `value` identifier, which is visited here.)
|
|
3287
|
+
for (const p of e.properties) visit(p.value)
|
|
3288
|
+
return
|
|
3289
|
+
case 'array-method':
|
|
3290
|
+
// Only `.includes(x)` is serializable ({@link toEvalNode}); its
|
|
3291
|
+
// `object` (the receiver) and `args` (the needle) are the value
|
|
3292
|
+
// positions serialized, so visit both when the tree reaches here
|
|
3293
|
+
// with that method. Every other `array-method` is non-serializable
|
|
3294
|
+
// and doesn't occur in a serializable body.
|
|
3295
|
+
if (e.method === 'includes') {
|
|
3296
|
+
visit(e.object)
|
|
3297
|
+
e.args.forEach(visit)
|
|
3298
|
+
}
|
|
3299
|
+
return
|
|
3300
|
+
// Non-serializable kinds don't occur in a serializable body
|
|
3301
|
+
// (serializeParsedExpr returns null for them); nothing to collect.
|
|
3302
|
+
case 'literal':
|
|
3303
|
+
case 'arrow':
|
|
3304
|
+
case 'regex':
|
|
3305
|
+
case 'unsupported':
|
|
3306
|
+
return
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
visit(body)
|
|
3310
|
+
return [...found].sort()
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
/**
|
|
3314
|
+
* Every value-position identifier in `expr` NOT bound by an enclosing arrow's
|
|
3315
|
+
* own parameters — with proper lexical scoping: an arrow's params bind only
|
|
3316
|
+
* within that arrow's body, and nested arrows accumulate onto the enclosing
|
|
3317
|
+
* bound set. Unlike {@link freeVarsInBody} (which assumes a single flat param
|
|
3318
|
+
* set and never recurses into nested `arrow` nodes, since a serializable
|
|
3319
|
+
* evaluator body never contains one), this walks the full source-level tree —
|
|
3320
|
+
* including arrows — so a caller can ask "is this name free ANYWHERE in the
|
|
3321
|
+
* expression, honoring each arrow's own scope" rather than only within one
|
|
3322
|
+
* callback body.
|
|
3323
|
+
*
|
|
3324
|
+
* Walks the same value positions as {@link serializeParsedExpr} /
|
|
3325
|
+
* {@link freeVarsInBody}: call callee (skipped when it resolves to an
|
|
3326
|
+
* evaluator builtin — see {@link evalBuiltinCalleeName} — so `Math.floor(x)`
|
|
3327
|
+
* doesn't report `Math` as free) + args, binary/logical/unary operands,
|
|
3328
|
+
* conditional branches, a member's OBJECT only (the property name is not a
|
|
3329
|
+
* reference), an index-access's object + index, template-literal expression
|
|
3330
|
+
* parts, array-literal elements, array-method object + args, and an
|
|
3331
|
+
* object-literal's property VALUES (not keys). An `arrow` recurses into its
|
|
3332
|
+
* body with its own params added to the bound set.
|
|
3333
|
+
*
|
|
3334
|
+
* Returns `null` when the tree contains an `unsupported` node (or any other
|
|
3335
|
+
* shape this walk can't analyze) — the caller must fail safe rather than
|
|
3336
|
+
* assume nothing is free.
|
|
3337
|
+
*/
|
|
3338
|
+
export function freeIdentifiers(expr: ParsedExpr): Set<string> | null {
|
|
3339
|
+
const free = new Set<string>()
|
|
3340
|
+
|
|
3341
|
+
function visit(e: ParsedExpr, bound: ReadonlySet<string>): boolean {
|
|
3342
|
+
switch (e.kind) {
|
|
3343
|
+
case 'literal':
|
|
3344
|
+
case 'regex':
|
|
3345
|
+
return true
|
|
3346
|
+
case 'identifier':
|
|
3347
|
+
if (!bound.has(e.name)) free.add(e.name)
|
|
3348
|
+
return true
|
|
3349
|
+
case 'call': {
|
|
3350
|
+
const isBuiltinCallee = evalBuiltinCalleeName(e.callee) !== null
|
|
3351
|
+
if (!isBuiltinCallee && !visit(e.callee, bound)) return false
|
|
3352
|
+
for (const a of e.args) if (!visit(a, bound)) return false
|
|
3353
|
+
return true
|
|
3354
|
+
}
|
|
3355
|
+
case 'member':
|
|
3356
|
+
return visit(e.object, bound)
|
|
3357
|
+
case 'index-access':
|
|
3358
|
+
return visit(e.object, bound) && visit(e.index, bound)
|
|
3359
|
+
case 'binary':
|
|
3360
|
+
case 'logical':
|
|
3361
|
+
return visit(e.left, bound) && visit(e.right, bound)
|
|
3362
|
+
case 'unary':
|
|
3363
|
+
return visit(e.argument, bound)
|
|
3364
|
+
case 'conditional':
|
|
3365
|
+
return visit(e.test, bound) && visit(e.consequent, bound) && visit(e.alternate, bound)
|
|
3366
|
+
case 'template-literal':
|
|
3367
|
+
for (const p of e.parts) {
|
|
3368
|
+
if (p.type === 'expression' && !visit(p.expr, bound)) return false
|
|
3369
|
+
}
|
|
3370
|
+
return true
|
|
3371
|
+
case 'array-literal':
|
|
3372
|
+
for (const el of e.elements) if (!visit(el, bound)) return false
|
|
3373
|
+
return true
|
|
3374
|
+
case 'array-method':
|
|
3375
|
+
if (!visit(e.object, bound)) return false
|
|
3376
|
+
for (const a of e.args) if (!visit(a, bound)) return false
|
|
3377
|
+
return true
|
|
3378
|
+
case 'object-literal':
|
|
3379
|
+
for (const p of e.properties) if (!visit(p.value, bound)) return false
|
|
3380
|
+
return true
|
|
3381
|
+
case 'arrow': {
|
|
3382
|
+
const inner = new Set(bound)
|
|
3383
|
+
for (const p of e.params) inner.add(p)
|
|
3384
|
+
return visit(e.body, inner)
|
|
3385
|
+
}
|
|
3386
|
+
case 'unsupported':
|
|
3387
|
+
return false
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
return visit(expr, new Set()) ? free : null
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
// Operators the evaluator implements (Go `eval.go` evalBinary / evalUnary, Perl
|
|
3395
|
+
// `Evaluator.pm` _binary / _unary). An op outside these sets — loose `==`,
|
|
3396
|
+
// `instanceof`, `**`, bitwise/shift, or the parser's `'unknown'` sentinel — is
|
|
3397
|
+
// refused so the body falls back to BF101 rather than serializing an op the
|
|
3398
|
+
// evaluator would silently mis-handle.
|
|
3399
|
+
const EVAL_BINARY_OPS: ReadonlySet<string> = new Set([
|
|
3400
|
+
'+', '-', '*', '/', '%', '<', '<=', '>', '>=', '===', '!==',
|
|
3401
|
+
])
|
|
3402
|
+
const EVAL_UNARY_OPS: ReadonlySet<string> = new Set(['!', '-', '+'])
|
|
3403
|
+
|
|
3404
|
+
// The only call shapes the evaluator executes (Go `eval.go` evalBuiltinName /
|
|
3405
|
+
// evalCallBuiltin, Perl `Evaluator.pm` _call_builtin): a bare `String` / `Number`
|
|
3406
|
+
// / `Boolean`, or a NON-computed `Math.<fn>` for a fixed `<fn>` set. Any other
|
|
3407
|
+
// callee — a bare function (`foo(x)`), a method (`x.bar(...)`), or a *computed*
|
|
3408
|
+
// builtin (`Math['max']`, which the evaluator rejects) — evaluates to nil at
|
|
3409
|
+
// runtime, so the gate refuses it at compile time instead (BF101 / `@client`).
|
|
3410
|
+
const EVAL_BUILTIN_IDENTS: ReadonlySet<string> = new Set(['String', 'Number', 'Boolean'])
|
|
3411
|
+
const EVAL_MATH_METHODS: ReadonlySet<string> = new Set([
|
|
3412
|
+
'max', 'min', 'abs', 'floor', 'ceil', 'round',
|
|
3413
|
+
])
|
|
3414
|
+
|
|
3415
|
+
/** The allowlisted builtin name a call callee resolves to (`Math.max` / `String`), or null. */
|
|
3416
|
+
function evalBuiltinCalleeName(callee: ParsedExpr): string | null {
|
|
3417
|
+
if (callee.kind === 'identifier') {
|
|
3418
|
+
return EVAL_BUILTIN_IDENTS.has(callee.name) ? callee.name : null
|
|
3419
|
+
}
|
|
3420
|
+
if (
|
|
3421
|
+
callee.kind === 'member' &&
|
|
3422
|
+
!callee.computed &&
|
|
3423
|
+
callee.object.kind === 'identifier' &&
|
|
3424
|
+
callee.object.name === 'Math' &&
|
|
3425
|
+
EVAL_MATH_METHODS.has(callee.property)
|
|
3426
|
+
) {
|
|
3427
|
+
return `Math.${callee.property}`
|
|
3428
|
+
}
|
|
3429
|
+
return null
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
/** Build the evaluator's minimal node object, or null for an out-of-surface kind. */
|
|
3433
|
+
function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
|
|
3434
|
+
switch (e.kind) {
|
|
3435
|
+
case 'literal':
|
|
3436
|
+
return { kind: 'literal', value: e.value }
|
|
3437
|
+
case 'identifier':
|
|
3438
|
+
return { kind: 'identifier', name: e.name }
|
|
3439
|
+
case 'binary': {
|
|
3440
|
+
if (!EVAL_BINARY_OPS.has(e.op)) return null
|
|
3441
|
+
const left = toEvalNode(e.left)
|
|
3442
|
+
const right = toEvalNode(e.right)
|
|
3443
|
+
return left && right ? { kind: 'binary', op: e.op, left, right } : null
|
|
3444
|
+
}
|
|
3445
|
+
case 'logical': {
|
|
3446
|
+
// `op` is the fixed `&&` | `||` | `??` union — all evaluator-supported.
|
|
3447
|
+
const left = toEvalNode(e.left)
|
|
3448
|
+
const right = toEvalNode(e.right)
|
|
3449
|
+
return left && right ? { kind: 'logical', op: e.op, left, right } : null
|
|
3450
|
+
}
|
|
3451
|
+
case 'unary': {
|
|
3452
|
+
if (!EVAL_UNARY_OPS.has(e.op)) return null
|
|
3453
|
+
const argument = toEvalNode(e.argument)
|
|
3454
|
+
return argument ? { kind: 'unary', op: e.op, argument } : null
|
|
3455
|
+
}
|
|
3456
|
+
case 'conditional': {
|
|
3457
|
+
const test = toEvalNode(e.test)
|
|
3458
|
+
const consequent = toEvalNode(e.consequent)
|
|
3459
|
+
const alternate = toEvalNode(e.alternate)
|
|
3460
|
+
return test && consequent && alternate
|
|
3461
|
+
? { kind: 'conditional', test, consequent, alternate }
|
|
3462
|
+
: null
|
|
3463
|
+
}
|
|
3464
|
+
case 'member': {
|
|
3465
|
+
const object = toEvalNode(e.object)
|
|
3466
|
+
if (!object) return null
|
|
3467
|
+
// Carry `computed` only when set (absent reads as `false`): the evaluator
|
|
3468
|
+
// reads it to reject a computed builtin (`Math['max']`), so preserving it
|
|
3469
|
+
// keeps a computed member distinguishable from a plain `.prop` access. (A
|
|
3470
|
+
// computed builtin *call* is already refused by the callee gate above.)
|
|
3471
|
+
const node: Record<string, unknown> = { kind: 'member', object, property: e.property }
|
|
3472
|
+
if (e.computed) node.computed = true
|
|
3473
|
+
return node
|
|
3474
|
+
}
|
|
3475
|
+
case 'index-access': {
|
|
3476
|
+
const object = toEvalNode(e.object)
|
|
3477
|
+
const index = toEvalNode(e.index)
|
|
3478
|
+
return object && index ? { kind: 'index-access', object, index } : null
|
|
3479
|
+
}
|
|
3480
|
+
case 'call': {
|
|
3481
|
+
// The evaluator executes only the builtin allowlist; a non-builtin callee
|
|
3482
|
+
// would evaluate to nil at runtime, so refuse it here (the purity gate).
|
|
3483
|
+
if (evalBuiltinCalleeName(e.callee) === null) return null
|
|
3484
|
+
const callee = toEvalNode(e.callee)
|
|
3485
|
+
if (!callee) return null
|
|
3486
|
+
const args: Record<string, unknown>[] = []
|
|
3487
|
+
for (const a of e.args) {
|
|
3488
|
+
const c = toEvalNode(a)
|
|
3489
|
+
if (!c) return null
|
|
3490
|
+
args.push(c)
|
|
3491
|
+
}
|
|
3492
|
+
return { kind: 'call', callee, args }
|
|
3493
|
+
}
|
|
3494
|
+
case 'template-literal': {
|
|
3495
|
+
const parts: Record<string, unknown>[] = []
|
|
3496
|
+
for (const p of e.parts) {
|
|
3497
|
+
if (p.type === 'string') {
|
|
3498
|
+
parts.push({ type: 'string', value: p.value })
|
|
3499
|
+
} else {
|
|
3500
|
+
const expr = toEvalNode(p.expr)
|
|
3501
|
+
if (!expr) return null
|
|
3502
|
+
parts.push({ type: 'expression', expr })
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
return { kind: 'template-literal', parts }
|
|
3506
|
+
}
|
|
3507
|
+
case 'array-literal': {
|
|
3508
|
+
const elements: Record<string, unknown>[] = []
|
|
3509
|
+
for (const el of e.elements) {
|
|
3510
|
+
const c = toEvalNode(el)
|
|
3511
|
+
if (!c) return null
|
|
3512
|
+
elements.push(c)
|
|
3513
|
+
}
|
|
3514
|
+
return { kind: 'array-literal', elements }
|
|
3515
|
+
}
|
|
3516
|
+
case 'object-literal': {
|
|
3517
|
+
const properties: Record<string, unknown>[] = []
|
|
3518
|
+
for (const p of e.properties) {
|
|
3519
|
+
const value = toEvalNode(p.value)
|
|
3520
|
+
if (!value) return null
|
|
3521
|
+
properties.push({ key: p.key, value })
|
|
3522
|
+
}
|
|
3523
|
+
return { kind: 'object-literal', properties }
|
|
3524
|
+
}
|
|
3525
|
+
case 'array-method': {
|
|
3526
|
+
// `.includes(x)` is the one `array-method` the evaluator executes
|
|
3527
|
+
// (Go `eval.go` / Perl `Evaluator.pm`, includes support): the
|
|
3528
|
+
// receiver-type dispatch (array SameValueZero membership vs string
|
|
3529
|
+
// substring) happens at evaluator runtime, same as the SSR template
|
|
3530
|
+
// lowering's `bf_includes` / `$bf->includes`. Every other
|
|
3531
|
+
// `array-method` (`join`, `slice`, `flat`, …) is outside the
|
|
3532
|
+
// evaluator's surface and refuses below.
|
|
3533
|
+
if (e.method === 'includes' && e.args.length === 1) {
|
|
3534
|
+
const object = toEvalNode(e.object)
|
|
3535
|
+
const arg = toEvalNode(e.args[0])
|
|
3536
|
+
return object && arg ? { kind: 'array-method', method: 'includes', object, args: [arg] } : null
|
|
3537
|
+
}
|
|
3538
|
+
return null
|
|
3539
|
+
}
|
|
3540
|
+
// Outside the evaluator's pure-expression surface — refuse so the caller
|
|
3541
|
+
// falls back to BF101 / `@client`. A nested `arrow` (a callback inside the
|
|
3542
|
+
// body) is refused here, keeping the evaluator non-recursive.
|
|
3543
|
+
case 'arrow':
|
|
3544
|
+
case 'regex':
|
|
3545
|
+
case 'unsupported':
|
|
3546
|
+
return null
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3104
3550
|
/**
|
|
3105
3551
|
* Extract the textual identifier path from a parsed expression's
|
|
3106
3552
|
* callee — `{kind:'identifier', name:'String'}` → `"String"`,
|