@jbrowse/jexl 4.0.1 → 5.0.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.
Files changed (110) hide show
  1. package/README.md +163 -20
  2. package/dist/Expression.d.ts +16 -0
  3. package/dist/Expression.js +28 -4
  4. package/dist/Expression.js.map +1 -1
  5. package/dist/Jexl.d.ts +20 -8
  6. package/dist/Jexl.js +34 -15
  7. package/dist/Jexl.js.map +1 -1
  8. package/dist/Lexer.d.ts +4 -3
  9. package/dist/Lexer.js +29 -21
  10. package/dist/Lexer.js.map +1 -1
  11. package/dist/analyze.d.ts +84 -0
  12. package/dist/analyze.js +259 -0
  13. package/dist/analyze.js.map +1 -0
  14. package/dist/check.d.ts +109 -0
  15. package/dist/check.js +1267 -0
  16. package/dist/check.js.map +1 -0
  17. package/dist/collections.d.ts +3 -0
  18. package/dist/collections.js +140 -0
  19. package/dist/collections.js.map +1 -0
  20. package/dist/conditions.d.ts +62 -0
  21. package/dist/conditions.js +207 -0
  22. package/dist/conditions.js.map +1 -0
  23. package/dist/errors.d.ts +5 -0
  24. package/dist/errors.js +18 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/evaluator/compile.d.ts +10 -7
  27. package/dist/evaluator/compile.js +292 -99
  28. package/dist/evaluator/compile.js.map +1 -1
  29. package/dist/grammar.d.ts +22 -9
  30. package/dist/grammar.js +83 -55
  31. package/dist/grammar.js.map +1 -1
  32. package/dist/index.d.ts +9 -0
  33. package/dist/index.js +16 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/operators.d.ts +34 -0
  36. package/dist/operators.js +131 -0
  37. package/dist/operators.js.map +1 -0
  38. package/dist/parser/Parser.d.ts +48 -91
  39. package/dist/parser/Parser.js +347 -221
  40. package/dist/parser/Parser.js.map +1 -1
  41. package/dist/types.d.ts +11 -5
  42. package/esm/Expression.d.ts +16 -0
  43. package/esm/Expression.js +28 -4
  44. package/esm/Expression.js.map +1 -1
  45. package/esm/Jexl.d.ts +20 -8
  46. package/esm/Jexl.js +34 -15
  47. package/esm/Jexl.js.map +1 -1
  48. package/esm/Lexer.d.ts +4 -3
  49. package/esm/Lexer.js +29 -21
  50. package/esm/Lexer.js.map +1 -1
  51. package/esm/analyze.d.ts +84 -0
  52. package/esm/analyze.js +256 -0
  53. package/esm/analyze.js.map +1 -0
  54. package/esm/check.d.ts +109 -0
  55. package/esm/check.js +1260 -0
  56. package/esm/check.js.map +1 -0
  57. package/esm/collections.d.ts +3 -0
  58. package/esm/collections.js +137 -0
  59. package/esm/collections.js.map +1 -0
  60. package/esm/conditions.d.ts +62 -0
  61. package/esm/conditions.js +200 -0
  62. package/esm/conditions.js.map +1 -0
  63. package/esm/errors.d.ts +5 -0
  64. package/esm/errors.js +14 -0
  65. package/esm/errors.js.map +1 -0
  66. package/esm/evaluator/compile.d.ts +10 -7
  67. package/esm/evaluator/compile.js +292 -99
  68. package/esm/evaluator/compile.js.map +1 -1
  69. package/esm/grammar.d.ts +22 -9
  70. package/esm/grammar.js +79 -53
  71. package/esm/grammar.js.map +1 -1
  72. package/esm/index.d.ts +9 -0
  73. package/esm/index.js +4 -0
  74. package/esm/index.js.map +1 -1
  75. package/esm/operators.d.ts +34 -0
  76. package/esm/operators.js +123 -0
  77. package/esm/operators.js.map +1 -0
  78. package/esm/parser/Parser.d.ts +48 -91
  79. package/esm/parser/Parser.js +349 -192
  80. package/esm/parser/Parser.js.map +1 -1
  81. package/esm/types.d.ts +11 -5
  82. package/package.json +1 -1
  83. package/src/Expression.ts +34 -4
  84. package/src/Jexl.ts +52 -16
  85. package/src/Lexer.ts +32 -21
  86. package/src/analyze.ts +378 -0
  87. package/src/check.ts +1682 -0
  88. package/src/collections.ts +148 -0
  89. package/src/conditions.ts +262 -0
  90. package/src/errors.ts +15 -0
  91. package/src/evaluator/compile.ts +351 -102
  92. package/src/grammar.ts +137 -55
  93. package/src/index.ts +15 -0
  94. package/src/operators.ts +151 -0
  95. package/src/parser/Parser.ts +389 -199
  96. package/src/types.ts +13 -3
  97. package/dist/parser/handlers.d.ts +0 -115
  98. package/dist/parser/handlers.js +0 -352
  99. package/dist/parser/handlers.js.map +0 -1
  100. package/dist/parser/states.d.ts +0 -47
  101. package/dist/parser/states.js +0 -176
  102. package/dist/parser/states.js.map +0 -1
  103. package/esm/parser/handlers.d.ts +0 -115
  104. package/esm/parser/handlers.js +0 -328
  105. package/esm/parser/handlers.js.map +0 -1
  106. package/esm/parser/states.d.ts +0 -47
  107. package/esm/parser/states.js +0 -140
  108. package/esm/parser/states.js.map +0 -1
  109. package/src/parser/handlers.ts +0 -372
  110. package/src/parser/states.ts +0 -159
@@ -3,8 +3,16 @@
3
3
  * Copyright 2020 Tom Shawver
4
4
  */
5
5
 
6
+ import { and, nullish, or } from '../grammar.ts'
7
+
6
8
  import type { Grammar } from '../grammar.ts'
7
- import type { AstNode, AstNodeUnion, JexlValue } from '../types.ts'
9
+ import type {
10
+ AstNode,
11
+ AstNodeUnion,
12
+ JexlFunction,
13
+ JexlValue,
14
+ Literal
15
+ } from '../types.ts'
8
16
 
9
17
  /** The variables an expression is evaluated against. */
10
18
  export type Context = Record<string, JexlValue>
@@ -36,15 +44,24 @@ function assignOwn(target: Context, key: string, value: JexlValue) {
36
44
  target[key] = value
37
45
  }
38
46
 
47
+ /** The key `subject[index]` reads under, or undefined when it reads nothing. */
48
+ function memberKey(index: JexlValue) {
49
+ return typeof index === 'string' || typeof index === 'number'
50
+ ? index
51
+ : indexKey(index)
52
+ }
53
+
39
54
  /**
40
55
  * The key a bracket index that is neither a string nor a number reads under:
41
56
  * its string form, when it has one worth using.
42
57
  *
43
58
  * A boolean and an array of primitives do — `['a']` is the key `'a'`, exactly
44
- * as JS would index by it. A plain object does not, since '[object Object]' is
45
- * not a key anything is stored under, so it and an array holding one answer
46
- * `undefined` instead; indexing by either reached that lookup and missed
47
- * anyway. null and undefined are the same case.
59
+ * as JS would index by it, and how a `@gmod/vcf` INFO value (always a list,
60
+ * `Number=1` included) indexes a lookup table. A multi-valued list is the key
61
+ * `'a,b'`, and misses rather than guess which value was meant. A plain object
62
+ * does not, since '[object Object]' is not a key anything is stored under, so
63
+ * it and an array holding one answer `undefined` instead. null and undefined
64
+ * are the same case.
48
65
  */
49
66
  function indexKey(value: JexlValue): string | undefined {
50
67
  if (typeof value === 'boolean') {
@@ -66,19 +83,197 @@ function isKeyPart(
66
83
  )
67
84
  }
68
85
 
69
- /** Renders an interpolated value for a template literal. */
70
- function stringify(value: JexlValue) {
71
- if (value == null) {
72
- return ''
86
+ /**
87
+ * The value of an object or array literal holding only primitives, built once.
88
+ * Indexing such a table, as in `{CDS: 'red', exon: 'blue'}[feature.type]`,
89
+ * can only yield a primitive, so the table never escapes and one frozen copy
90
+ * serves every evaluation.
91
+ */
92
+ function literalTable(node: AstNode) {
93
+ const literal = node as AstNodeUnion
94
+ if (literal.type === 'ArrayLiteral') {
95
+ return literal.value.every((item) => item.type === 'Literal')
96
+ ? Object.freeze(literal.value.map((item) => (item as Literal).value))
97
+ : undefined
73
98
  }
74
- if (
75
- typeof value === 'string' ||
76
- typeof value === 'number' ||
77
- typeof value === 'boolean'
99
+ if (literal.type === 'ObjectLiteral') {
100
+ const entries = Object.entries(literal.value)
101
+ if (entries.every(([, value]) => value.type === 'Literal')) {
102
+ const table: Context = {}
103
+ for (const [key, value] of entries) {
104
+ defineOwn(table, key, (value as Literal).value)
105
+ }
106
+ return Object.freeze(table)
107
+ }
108
+ }
109
+ return undefined
110
+ }
111
+
112
+ const unassigned = Symbol('unassigned')
113
+
114
+ /**
115
+ * The slot each name an expression assigns is kept in, and the frame holding
116
+ * those slots for the evaluation in progress.
117
+ */
118
+ interface Locals {
119
+ slots: ReadonlyMap<string, number>
120
+ frame: { locals: unknown[] }
121
+ }
122
+
123
+ /**
124
+ * One call of a lambda. A name the lambda binds reads from `args`, a name bound
125
+ * by a lambda around it from `outer`, a name the expression assigns from
126
+ * `locals` — the evaluation's own, captured when the lambda was made, so a
127
+ * lambda called after its evaluation ends still sees them — and any other name
128
+ * from `context`.
129
+ */
130
+ class LambdaFrame {
131
+ args: JexlValue[]
132
+ outer: LambdaFrame | undefined
133
+ context: Context
134
+ locals: unknown[] | undefined
135
+
136
+ constructor(
137
+ args: JexlValue[],
138
+ outer: LambdaFrame | undefined,
139
+ context: Context,
140
+ locals: unknown[] | undefined
78
141
  ) {
79
- return String(value)
142
+ this.args = args
143
+ this.outer = outer
144
+ this.context = context
145
+ this.locals = locals
80
146
  }
81
- return JSON.stringify(value)
147
+ }
148
+
149
+ /** The parameters of the lambdas around a node, innermost first. */
150
+ interface Params {
151
+ names: string[]
152
+ outer: Params | undefined
153
+ }
154
+
155
+ interface Scope {
156
+ locals?: Locals
157
+ params?: Params
158
+ }
159
+
160
+ // the closures compiled inside a lambda are handed its LambdaFrame in place of
161
+ // the context, and only the ones that read a name, below, look inside it
162
+ const asFrame = (env: Context) => env as unknown as LambdaFrame
163
+ const asContext = (frame: LambdaFrame) => frame as unknown as Context
164
+
165
+ function compileName(
166
+ name: string,
167
+ grammar: Grammar,
168
+ scope: Scope
169
+ ): CompiledNode {
170
+ let hops = 0
171
+ for (let p = scope.params; p; p = p.outer) {
172
+ const index = p.names.indexOf(name)
173
+ if (index !== -1) {
174
+ return hops === 0 ? (env) => asFrame(env).args[index] : climb(hops, index)
175
+ }
176
+ hops++
177
+ }
178
+ const read =
179
+ (grammar.variableReader?.(name) as CompiledNode | undefined) ??
180
+ ((ctx) => ctx[name])
181
+ const fromContext: CompiledNode = scope.params
182
+ ? (env) => read(asFrame(env).context)
183
+ : read
184
+ const slot = scope.locals?.slots.get(name)
185
+ if (slot === undefined) {
186
+ return fromContext
187
+ }
188
+ if (scope.params) {
189
+ return (env) => {
190
+ const local = asFrame(env).locals![slot]
191
+ return local === unassigned ? fromContext(env) : (local as JexlValue)
192
+ }
193
+ }
194
+ const { frame } = scope.locals!
195
+ return (env) => {
196
+ const local = frame.locals[slot]
197
+ return local === unassigned ? fromContext(env) : (local as JexlValue)
198
+ }
199
+ }
200
+
201
+ function climb(hops: number, index: number): CompiledNode {
202
+ return (env) => {
203
+ let frame = asFrame(env)
204
+ for (let i = 0; i < hops; i++) {
205
+ frame = frame.outer!
206
+ }
207
+ return frame.args[index]
208
+ }
209
+ }
210
+
211
+ function assignedNames(ast: AstNode | undefined, names = new Set<string>()) {
212
+ const node = ast as AstNodeUnion | undefined
213
+ switch (node?.type) {
214
+ case 'AssignmentExpression': {
215
+ names.add(node.left.value)
216
+ assignedNames(node.right, names)
217
+ break
218
+ }
219
+ case 'Identifier': {
220
+ assignedNames(node.from, names)
221
+ break
222
+ }
223
+ case 'BinaryExpression': {
224
+ assignedNames(node.left, names)
225
+ assignedNames(node.right, names)
226
+ break
227
+ }
228
+ case 'UnaryExpression': {
229
+ assignedNames(node.right, names)
230
+ break
231
+ }
232
+ case 'ConditionalExpression': {
233
+ assignedNames(node.test, names)
234
+ assignedNames(node.consequent, names)
235
+ assignedNames(node.alternate, names)
236
+ break
237
+ }
238
+ case 'FilterExpression': {
239
+ assignedNames(node.subject, names)
240
+ assignedNames(node.expr, names)
241
+ break
242
+ }
243
+ case 'ArrayLiteral':
244
+ case 'FunctionCall':
245
+ case 'SequenceExpression': {
246
+ const children =
247
+ node.type === 'ArrayLiteral'
248
+ ? node.value
249
+ : node.type === 'FunctionCall'
250
+ ? node.args
251
+ : node.expressions
252
+ for (const child of children) {
253
+ assignedNames(child, names)
254
+ }
255
+ break
256
+ }
257
+ case 'ObjectLiteral': {
258
+ for (const child of Object.values(node.value)) {
259
+ assignedNames(child, names)
260
+ }
261
+ break
262
+ }
263
+ case 'TemplateLiteral': {
264
+ for (const part of node.parts) {
265
+ if (part.type === 'expression') {
266
+ assignedNames(part.value, names)
267
+ }
268
+ }
269
+ break
270
+ }
271
+ case 'Lambda': {
272
+ assignedNames(node.body, names)
273
+ break
274
+ }
275
+ }
276
+ return names
82
277
  }
83
278
 
84
279
  /**
@@ -90,16 +285,61 @@ function stringify(value: JexlValue) {
90
285
  * times.
91
286
  *
92
287
  * Operators are bound here, so a grammar change after compilation requires
93
- * recompiling, which is what {@link Expression#compile} already documents.
94
- * Functions are looked up per call, since they are commonly registered after
95
- * an expression has been compiled.
288
+ * recompiling. Functions are looked up per call, since they are commonly
289
+ * registered after an expression has been compiled.
96
290
  *
97
- * @param {{}} ast An expression tree, as produced by the Parser
98
- * @param {{}} grammar The grammar to resolve operators against
99
- * @returns {function} a closure returning the expression's value
100
- * @throws {Error} if the tree contains an unrecognized node type
291
+ * Each name the expression assigns gets a slot, numbered at compile time, in a
292
+ * frame that lasts one evaluation, so an assignment never touches the context
293
+ * and a context reused from one evaluation to the next comes back unchanged. A
294
+ * name reads its slot once assigned, and resolves as usual until then. An
295
+ * expression that assigns nothing gets no frame. The frame is not a layer over
296
+ * the context via `Object.create`: V8 gives every object used as a prototype a
297
+ * map of its own, which made evaluating against a context built per evaluation
298
+ * six times slower.
101
299
  */
102
300
  export function compileAst(ast: AstNode, grammar: Grammar): CompiledNode {
301
+ const names = [...assignedNames(ast)]
302
+ if (names.length === 0) {
303
+ return compileNode(ast, grammar, {})
304
+ }
305
+ const slots = new Map(names.map((name, i) => [name, i]))
306
+ const empty = names.map(() => unassigned)
307
+ const frame = { locals: empty }
308
+ const fn = compileNode(ast, grammar, { locals: { slots, frame } })
309
+ // saved and restored, so a function that evaluates this same expression
310
+ // again from inside it leaves the outer evaluation's locals intact
311
+ return (ctx) => {
312
+ const outer = frame.locals
313
+ frame.locals = empty.slice()
314
+ try {
315
+ return fn(ctx)
316
+ } finally {
317
+ frame.locals = outer
318
+ }
319
+ }
320
+ }
321
+
322
+ /** Renders an interpolated value for a template literal. */
323
+ function stringify(value: JexlValue) {
324
+ if (value == null) {
325
+ return ''
326
+ }
327
+ if (
328
+ typeof value === 'string' ||
329
+ typeof value === 'number' ||
330
+ typeof value === 'boolean'
331
+ ) {
332
+ return String(value)
333
+ }
334
+ return JSON.stringify(value)
335
+ }
336
+
337
+ function compileNode(
338
+ ast: AstNode,
339
+ grammar: Grammar,
340
+ scope: Scope
341
+ ): CompiledNode {
342
+ const compile = (child: AstNode) => compileNode(child, grammar, scope)
103
343
  // AstNode types its `type` as a plain string so the Parser can build the tree
104
344
  // loosely; narrowing to the union once, here, lets every case below see its
105
345
  // own node type instead of repeating the same cast in each branch
@@ -113,49 +353,59 @@ export function compileAst(ast: AstNode, grammar: Grammar): CompiledNode {
113
353
  case 'Identifier': {
114
354
  const name = node.value
115
355
  if (!node.from) {
116
- return (ctx) => ctx[name]
356
+ return compileName(name, grammar, scope)
117
357
  }
118
- const from = compileAst(node.from, grammar)
119
- return (ctx) => {
120
- const subject = from(ctx)
121
- if (subject == null) {
122
- return undefined
358
+ const from = compile(node.from)
359
+ const { getMember } = grammar
360
+ if (getMember) {
361
+ return (ctx) => {
362
+ const target = from(ctx)
363
+ return target == null
364
+ ? undefined
365
+ : (getMember(target, name) as JexlValue)
123
366
  }
124
- // an identifier chained off an array reads through its first element
125
- const target = Array.isArray(subject) ? subject[0] : subject
367
+ }
368
+ return (ctx) => {
369
+ const target = from(ctx)
126
370
  return target == null ? undefined : (target as Context)[name]
127
371
  }
128
372
  }
129
373
 
130
374
  case 'BinaryExpression': {
131
375
  const op = grammar.elements[node.operator]
132
- if (op?.type !== 'binaryOp') {
133
- // matches the tree-walking behaviour: an unknown operator yields
134
- // undefined without evaluating either operand
135
- return () => undefined
136
- }
137
- const left = compileAst(node.left, grammar)
138
- const right = compileAst(node.right!, grammar)
139
- const { evalOnDemand } = op
140
- if (evalOnDemand) {
141
- // operands stay unevaluated behind an `eval` thunk, so operators such
142
- // as && and || can short-circuit
376
+ const left = compile(node.left)
377
+ const right = compile(node.right!)
378
+ if (op?.type === 'binaryOp' && op.evalOnDemand) {
379
+ const { evalOnDemand } = op
380
+ if (evalOnDemand === and) {
381
+ return (ctx) => {
382
+ const value = left(ctx)
383
+ return value ? right(ctx) : value
384
+ }
385
+ }
386
+ if (evalOnDemand === or) {
387
+ return (ctx) => {
388
+ const value = left(ctx)
389
+ return value || right(ctx)
390
+ }
391
+ }
392
+ if (evalOnDemand === nullish) {
393
+ return (ctx) => left(ctx) ?? right(ctx)
394
+ }
395
+ // a host's operator gets its operands unevaluated, behind `eval`
396
+ // thunks, so that it can short-circuit as it chooses
143
397
  return (ctx) =>
144
398
  evalOnDemand({ eval: () => left(ctx) }, { eval: () => right(ctx) })
145
399
  }
146
- const fn = op.eval
400
+ const fn = op?.type === 'binaryOp' ? op.eval : undefined
147
401
  if (!fn) {
148
- return (ctx) => {
149
- left(ctx)
150
- right(ctx)
151
- return undefined
152
- }
402
+ throw new Error(`Unknown binary operator '${node.operator}'`)
153
403
  }
154
404
  return (ctx) => fn(left(ctx), right(ctx))
155
405
  }
156
406
 
157
407
  case 'UnaryExpression': {
158
- const right = compileAst(node.right!, grammar)
408
+ const right = compile(node.right!)
159
409
  const elem = grammar.elements[node.operator]
160
410
  const fn =
161
411
  elem?.type === 'unaryOp'
@@ -164,23 +414,15 @@ export function compileAst(ast: AstNode, grammar: Grammar): CompiledNode {
164
414
  ? elem.unaryEval
165
415
  : undefined
166
416
  if (!fn) {
167
- // the operand is still evaluated for an unknown operator, as before
168
- return (ctx) => {
169
- right(ctx)
170
- return undefined
171
- }
417
+ throw new Error(`Unknown unary operator '${node.operator}'`)
172
418
  }
173
419
  return (ctx) => fn(right(ctx))
174
420
  }
175
421
 
176
422
  case 'ConditionalExpression': {
177
- const test = compileAst(node.test, grammar)
178
- const consequent = node.consequent
179
- ? compileAst(node.consequent, grammar)
180
- : undefined
181
- const alternate = node.alternate
182
- ? compileAst(node.alternate, grammar)
183
- : undefined
423
+ const test = compile(node.test)
424
+ const consequent = node.consequent ? compile(node.consequent) : undefined
425
+ const alternate = node.alternate ? compile(node.alternate) : undefined
184
426
  return (ctx) => {
185
427
  const res = test(ctx)
186
428
  if (res) {
@@ -192,44 +434,30 @@ export function compileAst(ast: AstNode, grammar: Grammar): CompiledNode {
192
434
  }
193
435
 
194
436
  case 'FilterExpression': {
195
- const subject = compileAst(node.subject, grammar)
196
- const index = compileAst(node.expr, grammar)
437
+ const table = literalTable(node.subject)
438
+ const subject = table ? () => table : compile(node.subject)
439
+ const index = compile(node.expr)
440
+ const { getMember } = grammar
441
+ if (getMember) {
442
+ return (ctx) => {
443
+ const subjectVal = subject(ctx)
444
+ const key = memberKey(index(ctx))
445
+ return subjectVal == null || key === undefined
446
+ ? undefined
447
+ : (getMember(subjectVal, key) as JexlValue)
448
+ }
449
+ }
197
450
  return (ctx) => {
198
451
  const subjectVal = subject(ctx)
199
- const indexVal = index(ctx)
200
- if (subjectVal == null) {
201
- return undefined
202
- }
203
- if (typeof indexVal === 'string' || typeof indexVal === 'number') {
204
- return (subjectVal as Record<string | number, JexlValue>)[indexVal]
205
- }
206
- // A value with a meaningful string form is indexed by it, which is what
207
- // JS does and what this did until the evaluator was lowered to closures
208
- // — the tree-walking version was a bare `subject?.[index]`. Answering
209
- // `undefined` for everything else reads as a deliberate strictness and
210
- // is not: nothing wanted it, and the shape it rejects is common.
211
- //
212
- // That shape is a one-element array. Every `@gmod/vcf` INFO value is a
213
- // list — `Number=1` included, so `CLNSIG=Pathogenic` parses to
214
- // `['Pathogenic']` — and the natural way to colour by one is a lookup
215
- // table indexed by it. The miss is silent, because such an expression
216
- // always carries a `|| fallback` for the values it has no entry for,
217
- // and that fallback then answers for every record.
218
- //
219
- // A multi-valued list still misses — `['a','b']` is the key 'a,b' —
220
- // which is the honest answer rather than a lucky one: reading through
221
- // to the first element would be the index guessing which value was
222
- // meant. `Identifier` above does read through a one-element array, but
223
- // it is navigating rather than selecting, so it has nothing to guess.
224
- const key = indexKey(indexVal)
225
- return key === undefined
452
+ const key = memberKey(index(ctx))
453
+ return subjectVal == null || key === undefined
226
454
  ? undefined
227
- : (subjectVal as Record<string, JexlValue>)[key]
455
+ : (subjectVal as Record<string | number, JexlValue>)[key]
228
456
  }
229
457
  }
230
458
 
231
459
  case 'ArrayLiteral': {
232
- const items = node.value.map((item) => compileAst(item, grammar))
460
+ const items = node.value.map(compile)
233
461
  const len = items.length
234
462
  return (ctx) => {
235
463
  const out: JexlValue[] = new Array(len)
@@ -243,7 +471,7 @@ export function compileAst(ast: AstNode, grammar: Grammar): CompiledNode {
243
471
  case 'ObjectLiteral': {
244
472
  const entries = Object.entries(node.value)
245
473
  const keys = entries.map(([key]) => key)
246
- const values = entries.map(([, value]) => compileAst(value, grammar))
474
+ const values = entries.map(([, value]) => compile(value))
247
475
  const len = keys.length
248
476
  // resolved once, here, so the common case keeps its plain store
249
477
  const store = keys.includes('__proto__') ? defineOwn : assignOwn
@@ -263,7 +491,7 @@ export function compileAst(ast: AstNode, grammar: Grammar): CompiledNode {
263
491
  const { value } = part
264
492
  return () => value
265
493
  }
266
- const expr = compileAst(part.value, grammar)
494
+ const expr = compile(part.value)
267
495
  return (ctx) => stringify(expr(ctx))
268
496
  })
269
497
  return (ctx) => {
@@ -277,7 +505,7 @@ export function compileAst(ast: AstNode, grammar: Grammar): CompiledNode {
277
505
 
278
506
  case 'FunctionCall': {
279
507
  const { name } = node
280
- const args = node.args.map((arg) => compileAst(arg, grammar))
508
+ const args = node.args.map(compile)
281
509
  // hasOwn, so that inherited Object.prototype members such as `toString`
282
510
  // and `constructor` aren't callable as Jexl functions. Resolved per call
283
511
  // rather than baked in, since functions are routinely registered after
@@ -325,7 +553,7 @@ export function compileAst(ast: AstNode, grammar: Grammar): CompiledNode {
325
553
  }
326
554
 
327
555
  case 'SequenceExpression': {
328
- const exprs = node.expressions.map((expr) => compileAst(expr, grammar))
556
+ const exprs = node.expressions.map(compile)
329
557
  const len = exprs.length
330
558
  return (ctx) => {
331
559
  let last: JexlValue
@@ -337,13 +565,34 @@ export function compileAst(ast: AstNode, grammar: Grammar): CompiledNode {
337
565
  }
338
566
 
339
567
  case 'AssignmentExpression': {
340
- const name = node.left.value
341
- const right = compileAst(node.right!, grammar)
342
- const store = name === '__proto__' ? defineOwn : assignOwn
343
- return (ctx) => {
344
- const value = right(ctx)
345
- store(ctx, name, value)
346
- return value
568
+ if (scope.params) {
569
+ throw new Error('Assignment is not supported in a lambda')
570
+ }
571
+ const right = compile(node.right!)
572
+ const { slots, frame } = scope.locals!
573
+ const slot = slots.get(node.left.value)!
574
+ return (ctx) => (frame.locals[slot] = right(ctx))
575
+ }
576
+
577
+ case 'Lambda': {
578
+ const body = compileNode(node.body, grammar, {
579
+ ...scope,
580
+ params: { names: node.params, outer: scope.params }
581
+ })
582
+ if (!scope.params) {
583
+ const frame = scope.locals?.frame
584
+ return (ctx): JexlFunction => {
585
+ const locals = frame?.locals
586
+ return (...args) =>
587
+ body(asContext(new LambdaFrame(args, undefined, ctx, locals)))
588
+ }
589
+ }
590
+ return (env): JexlFunction => {
591
+ const outer = asFrame(env)
592
+ return (...args) =>
593
+ body(
594
+ asContext(new LambdaFrame(args, outer, outer.context, outer.locals))
595
+ )
347
596
  }
348
597
  }
349
598