@jbrowse/jexl 4.0.1 → 5.0.0

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