@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
package/src/check.ts ADDED
@@ -0,0 +1,1682 @@
1
+ /*
2
+ * Jexl
3
+ * Copyright 2020 Tom Shawver
4
+ */
5
+
6
+ import type { AnalyzeOptions } from './analyze.ts'
7
+ import type {
8
+ AstNode,
9
+ AstNodeUnion,
10
+ BinaryExpression,
11
+ FunctionCall,
12
+ Identifier,
13
+ Lambda,
14
+ Literal
15
+ } from './types.ts'
16
+
17
+ /**
18
+ * How many values a field holds: one, a list of exactly that many when a
19
+ * number, or a list whose length follows the record, as VCF's `Number=A`, `R`,
20
+ * `G` and `.` do.
21
+ */
22
+ export type Cardinality =
23
+ | 'one'
24
+ | 'perAlt'
25
+ | 'perAllele'
26
+ | 'perGenotype'
27
+ | 'many'
28
+ | number
29
+
30
+ /**
31
+ * One field of a row, as a file header declares it. A `record` field's own
32
+ * fields are further entries whose path extends its path, including when the
33
+ * record is a list of them. A path key of `*` stands for every key of a map,
34
+ * such as a VCF record's sample names. An entry with an empty path describes
35
+ * the row itself.
36
+ */
37
+ export interface FieldSchema {
38
+ path: readonly string[]
39
+ type: 'number' | 'string' | 'boolean' | 'flag' | 'record'
40
+ cardinality?: Cardinality
41
+ integer?: boolean
42
+ description?: string
43
+ /** Every value the field takes, where the header enumerates them. */
44
+ categories?: readonly string[]
45
+ domain?: readonly [number, number]
46
+ /** For a record: its fields were observed in data rather than declared. */
47
+ open?: boolean
48
+ }
49
+
50
+ interface FromField {
51
+ field?: FieldSchema
52
+ }
53
+
54
+ export type Type =
55
+ | { kind: 'unknown' }
56
+ | { kind: 'undefined' }
57
+ | (FromField & {
58
+ kind: 'number'
59
+ values?: readonly number[]
60
+ domain?: readonly [number, number]
61
+ })
62
+ | (FromField & { kind: 'string'; values?: readonly string[] })
63
+ | (FromField & { kind: 'boolean' })
64
+ | (FromField & {
65
+ kind: 'list'
66
+ of: Type
67
+ cardinality: Exclude<Cardinality, 'one'>
68
+ })
69
+ | (FromField & {
70
+ kind: 'record'
71
+ fields: ReadonlyMap<string, Type>
72
+ each?: Type
73
+ open: boolean
74
+ })
75
+ | { kind: 'union'; of: readonly Type[] }
76
+ | { kind: 'lambda'; returns: Type }
77
+
78
+ type Kind = Type['kind']
79
+ type OfKind<K extends Kind> = Extract<Type, { kind: K }>
80
+
81
+ export type ParamType =
82
+ | 'number'
83
+ | 'string'
84
+ | 'boolean'
85
+ | 'list'
86
+ | 'record'
87
+ | 'any'
88
+ | 'member'
89
+ | 'lambda'
90
+
91
+ export interface Signature {
92
+ params: readonly (ParamType | readonly ParamType[])[]
93
+ /** How many trailing params a call may leave out. */
94
+ optional?: number
95
+ /** Whether the last param repeats. */
96
+ variadic?: boolean
97
+ returns?:
98
+ | 'number'
99
+ | 'string'
100
+ | 'boolean'
101
+ | Type
102
+ | ((args: readonly Type[]) => Type)
103
+ }
104
+
105
+ export type Severity = 'error' | 'warning' | 'info'
106
+
107
+ export type DiagnosticCode =
108
+ | 'unknown-variable'
109
+ | 'unknown-field'
110
+ | 'unknown-function'
111
+ | 'arity'
112
+ | 'argument-type'
113
+ | 'list-operand'
114
+ | 'list-lengths'
115
+ | 'invalid-pattern'
116
+ | 'string-numeric'
117
+ | 'never-equal'
118
+ | 'unknown-category'
119
+ | 'index-scalar'
120
+ | 'index-range'
121
+ | 'dot-through-list'
122
+ | 'dotted-key'
123
+
124
+ export interface Diagnostic {
125
+ code: DiagnosticCode
126
+ severity: Severity
127
+ message: string
128
+ node: AstNode
129
+ /** Replacements for `node`'s text, best first. */
130
+ suggestions?: string[]
131
+ }
132
+
133
+ export interface CheckOptions {
134
+ schema?: readonly FieldSchema[]
135
+ functions?: Readonly<Record<string, Signature>>
136
+ accessors?: AnalyzeOptions['accessors']
137
+ /** The variable holding the row. Without one, every free name is a field. */
138
+ row?: string
139
+ /**
140
+ * The other variables the host binds, and their types. When given, any name
141
+ * that is neither these, the row nor a field is reported.
142
+ */
143
+ env?: Readonly<Record<string, Type>>
144
+ }
145
+
146
+ export interface CheckResult {
147
+ diagnostics: Diagnostic[]
148
+ type: Type
149
+ }
150
+
151
+ type Node = AstNodeUnion
152
+
153
+ export const UNKNOWN: Type = { kind: 'unknown' }
154
+ const UNDEFINED: Type = { kind: 'undefined' }
155
+ const BOOLEAN: Type = { kind: 'boolean' }
156
+ const NUMBER: Type = { kind: 'number' }
157
+ const STRING: Type = { kind: 'string' }
158
+ const MAX_VALUES = 64
159
+
160
+ const ARITHMETIC = new Set(['-', '*', '/', '//', '%', '^'])
161
+ const ORDER = new Set(['<', '<=', '>', '>='])
162
+ const EQUALITY = new Set(['==', '!='])
163
+ const PRECEDENCE: Record<string, number> = {
164
+ '=': 2,
165
+ '||': 10,
166
+ '??': 10,
167
+ '&&': 11,
168
+ '==': 20,
169
+ '!=': 20,
170
+ '~': 20,
171
+ '!~': 20,
172
+ '<': 20,
173
+ '<=': 20,
174
+ '>': 20,
175
+ '>=': 20,
176
+ in: 20,
177
+ '+': 30,
178
+ '-': 30,
179
+ '*': 40,
180
+ '/': 40,
181
+ '//': 40,
182
+ '%': 50,
183
+ '^': 50
184
+ }
185
+
186
+ const RIGHT_ASSOCIATIVE = new Set(['^'])
187
+ const LOGICAL = new Set(['||', '&&'])
188
+
189
+ // how tightly each form binds, mirroring the Parser's levels: `;`, then `=`
190
+ // and lambdas, then `?:`, then binary operators, then prefix operators
191
+ const SEQUENCE = 0
192
+ const ASSIGNMENT = 1
193
+ const CONDITIONAL = 2
194
+ const CUSTOM_BINARY = 3
195
+ const UNARY = 100
196
+
197
+ const letter = String.raw`a-zA-Zа-яА-Я_À-ÖØ-öø-ÿ$`
198
+ const IDENTIFIER = new RegExp(`^[${letter}][${letter}0-9]*$`)
199
+ const WORDS = new Set(['in', 'true', 'false', 'null'])
200
+
201
+ function isName(text: string) {
202
+ return IDENTIFIER.test(text) && !WORDS.has(text)
203
+ }
204
+
205
+ function key(name: string) {
206
+ return isName(name) ? `.${name}` : `[${quote(name)}]`
207
+ }
208
+
209
+ function quote(text: string) {
210
+ return `'${text.replaceAll('\\', '\\\\').replaceAll("'", String.raw`\'`)}'`
211
+ }
212
+
213
+ function precedenceOf(node: Node) {
214
+ switch (node.type) {
215
+ case 'SequenceExpression': {
216
+ return SEQUENCE
217
+ }
218
+ case 'AssignmentExpression':
219
+ case 'Lambda': {
220
+ return ASSIGNMENT
221
+ }
222
+ case 'ConditionalExpression': {
223
+ // with no alternate, it ends only before `)`, `]`, `}`, `,`, `;` or the end
224
+ return node.alternate ? CONDITIONAL : SEQUENCE
225
+ }
226
+ case 'BinaryExpression': {
227
+ return PRECEDENCE[node.operator] ?? CUSTOM_BINARY
228
+ }
229
+ case 'UnaryExpression': {
230
+ return UNARY
231
+ }
232
+ default: {
233
+ return Infinity
234
+ }
235
+ }
236
+ }
237
+
238
+ function within(ast: AstNode, least: number) {
239
+ const text = print(ast)
240
+ return precedenceOf(ast as Node) < least ? `(${text})` : text
241
+ }
242
+
243
+ function binaryOperand(parent: BinaryExpression, ast: AstNode, left: boolean) {
244
+ const own = PRECEDENCE[parent.operator]
245
+ if (own === undefined) {
246
+ return within(ast, UNARY)
247
+ }
248
+ const child = ast as Node
249
+ const theirs = precedenceOf(child)
250
+ const chained =
251
+ child.type === 'BinaryExpression' &&
252
+ RIGHT_ASSOCIATIVE.has(parent.operator) &&
253
+ RIGHT_ASSOCIATIVE.has(child.operator)
254
+ const mixesNullish =
255
+ child.type === 'BinaryExpression' &&
256
+ ((parent.operator === '??' && LOGICAL.has(child.operator)) ||
257
+ (child.operator === '??' && LOGICAL.has(parent.operator)))
258
+ const text = print(ast)
259
+ return theirs < own || (theirs === own && left === chained) || mixesNullish
260
+ ? `(${text})`
261
+ : text
262
+ }
263
+
264
+ /**
265
+ * Renders a tree back to expression text, for messages and suggestions. The
266
+ * text parses back to the same tree.
267
+ */
268
+ export function print(ast: AstNode): string {
269
+ const node = ast as Node
270
+ switch (node.type) {
271
+ case 'Literal': {
272
+ const { value } = node
273
+ return typeof value === 'string'
274
+ ? quote(value)
275
+ : Object.is(value, -0)
276
+ ? '-0'
277
+ : String(value)
278
+ }
279
+ case 'Identifier': {
280
+ return node.from
281
+ ? within(node.from, Infinity) + key(node.value)
282
+ : node.value
283
+ }
284
+ case 'FilterExpression': {
285
+ return `${within(node.subject, Infinity)}[${within(node.expr, ASSIGNMENT)}]`
286
+ }
287
+ case 'BinaryExpression': {
288
+ return `${binaryOperand(node, node.left, true)} ${node.operator} ${binaryOperand(node, node.right!, false)}`
289
+ }
290
+ case 'UnaryExpression': {
291
+ const { operator } = node
292
+ const right = within(node.right!, UNARY)
293
+ // the Lexer folds a minus before a digit into the number
294
+ if (operator === '-' && /^\d/.test(right)) {
295
+ return `-(${right})`
296
+ }
297
+ return IDENTIFIER.test(operator)
298
+ ? `${operator} ${right}`
299
+ : operator + right
300
+ }
301
+ case 'ConditionalExpression': {
302
+ const test = within(node.test, CUSTOM_BINARY)
303
+ const consequent = node.consequent
304
+ ? ` ${within(node.consequent, ASSIGNMENT)} `
305
+ : ''
306
+ const alternate = node.alternate
307
+ ? ` ${within(node.alternate, ASSIGNMENT)}`
308
+ : ''
309
+ return `${test} ?${consequent}:${alternate}`
310
+ }
311
+ case 'FunctionCall': {
312
+ const args = node.args.map((arg) => within(arg, ASSIGNMENT))
313
+ return `${node.name}(${args.join(', ')})`
314
+ }
315
+ case 'ArrayLiteral': {
316
+ const items = node.value.map((item) => within(item, ASSIGNMENT))
317
+ return `[${items.join(', ')}]`
318
+ }
319
+ case 'ObjectLiteral': {
320
+ const entries = Object.entries(node.value).map(
321
+ ([name, value]) =>
322
+ `${isName(name) ? name : quote(name)}: ${within(value, ASSIGNMENT)}`
323
+ )
324
+ return `{${entries.join(', ')}}`
325
+ }
326
+ case 'TemplateLiteral': {
327
+ const parts = node.parts.map((part) =>
328
+ part.type === 'static'
329
+ ? part.value
330
+ .replaceAll('\\', '\\\\')
331
+ .replaceAll('`', '\\`')
332
+ .replaceAll('${', '\\${')
333
+ : `\${${print(part.value)}}`
334
+ )
335
+ return `\`${parts.join('')}\``
336
+ }
337
+ case 'SequenceExpression': {
338
+ const { expressions } = node
339
+ const text = expressions
340
+ .map((expr) => within(expr, ASSIGNMENT))
341
+ .join('; ')
342
+ return expressions.length === 1 ? `${text};` : text
343
+ }
344
+ case 'AssignmentExpression': {
345
+ return `${node.left.value} = ${within(node.right!, ASSIGNMENT)}`
346
+ }
347
+ case 'Lambda': {
348
+ const params =
349
+ node.params.length === 1
350
+ ? node.params[0]
351
+ : `(${node.params.join(', ')})`
352
+ return `${params} => ${within(node.body, ASSIGNMENT)}`
353
+ }
354
+ default: {
355
+ return '?'
356
+ }
357
+ }
358
+ }
359
+
360
+ function distance(a: string, b: string) {
361
+ let before: number[] = []
362
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i)
363
+ for (let i = 1; i <= a.length; i++) {
364
+ const current = [i]
365
+ for (let j = 1; j <= b.length; j++) {
366
+ current[j] = Math.min(
367
+ previous[j]! + 1,
368
+ current[j - 1]! + 1,
369
+ previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1)
370
+ )
371
+ if (a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
372
+ current[j] = Math.min(current[j]!, before[j - 2]! + 1)
373
+ }
374
+ }
375
+ before = previous
376
+ previous = current
377
+ }
378
+ return previous[b.length]!
379
+ }
380
+
381
+ function nearest(name: string, candidates: Iterable<string>) {
382
+ const lower = name.toLowerCase()
383
+ const limit = Math.max(1, Math.floor(name.length / 3))
384
+ const scored: [number, string][] = []
385
+ for (const candidate of candidates) {
386
+ const other = candidate.toLowerCase()
387
+ const score =
388
+ other === lower
389
+ ? 0
390
+ : other.startsWith(lower) && /^[^\w$]/.test(other.slice(lower.length))
391
+ ? 1
392
+ : distance(lower, other)
393
+ if (score <= limit) {
394
+ scored.push([score, candidate])
395
+ }
396
+ }
397
+ return scored
398
+ .sort(([a, x], [b, y]) => a - b || x.localeCompare(y))
399
+ .slice(0, 3)
400
+ .map(([, candidate]) => candidate)
401
+ }
402
+
403
+ function itemOf(field: FieldSchema): Type {
404
+ switch (field.type) {
405
+ case 'number': {
406
+ return field.domain
407
+ ? { kind: 'number', field, domain: field.domain }
408
+ : { kind: 'number', field }
409
+ }
410
+ case 'string': {
411
+ return field.categories
412
+ ? { kind: 'string', field, values: field.categories }
413
+ : { kind: 'string', field }
414
+ }
415
+ case 'boolean':
416
+ case 'flag': {
417
+ return { kind: 'boolean', field }
418
+ }
419
+ case 'record': {
420
+ return { kind: 'record', field, fields: new Map(), open: !!field.open }
421
+ }
422
+ }
423
+ }
424
+
425
+ function typeOfField(field: FieldSchema): Type {
426
+ const cardinality = field.cardinality ?? 'one'
427
+ const item = itemOf(field)
428
+ return cardinality === 'one'
429
+ ? item
430
+ : { kind: 'list', of: item, cardinality, field }
431
+ }
432
+
433
+ type MutableRecord = OfKind<'record'> & { fields: Map<string, Type> }
434
+
435
+ function recordWithin(type: Type): MutableRecord | undefined {
436
+ if (type.kind === 'record') {
437
+ return type as MutableRecord
438
+ }
439
+ return type.kind === 'list' ? recordWithin(type.of) : undefined
440
+ }
441
+
442
+ /** The type of a row whose fields `schema` declares. */
443
+ export function recordOf(schema: readonly FieldSchema[]): Type {
444
+ const own = schema.find((field) => field.path.length === 0)
445
+ const root: MutableRecord = {
446
+ kind: 'record',
447
+ fields: new Map(),
448
+ open: !!own?.open,
449
+ ...(own && { field: own })
450
+ }
451
+ const byDepth = schema
452
+ .filter((field) => field.path.length > 0)
453
+ .toSorted((a, b) => a.path.length - b.path.length)
454
+ for (const field of byDepth) {
455
+ let parent = root
456
+ for (const name of field.path.slice(0, -1)) {
457
+ const next = name === '*' ? parent.each : parent.fields.get(name)
458
+ let record = next && recordWithin(next)
459
+ if (!record) {
460
+ record = { kind: 'record', fields: new Map(), open: true }
461
+ if (name === '*') {
462
+ parent.each = record
463
+ } else {
464
+ parent.fields.set(name, record)
465
+ }
466
+ }
467
+ parent = record
468
+ }
469
+ const name = field.path.at(-1)!
470
+ const type = typeOfField(field)
471
+ if (name === '*') {
472
+ parent.each = type
473
+ } else {
474
+ parent.fields.set(name, type)
475
+ }
476
+ }
477
+ return root
478
+ }
479
+
480
+ function isMulti(type: Type): type is OfKind<'list'> {
481
+ return type.kind === 'list' && type.cardinality !== 1
482
+ }
483
+
484
+ function isBoxed(type: Type): type is OfKind<'list'> {
485
+ return type.kind === 'list' && type.cardinality === 1
486
+ }
487
+
488
+ function isPattern(pattern: string) {
489
+ try {
490
+ new RegExp(pattern.startsWith('(?i)') ? pattern.slice(4) : pattern)
491
+ return true
492
+ } catch {
493
+ return false
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Whether lists of two cardinalities pair value by value everywhere: they do
499
+ * when they are the same, or when either is a single value.
500
+ */
501
+ function pairs(a: Cardinality, b: Cardinality) {
502
+ return a === b || a === 1 || b === 1
503
+ }
504
+
505
+ function isNumberish(type: Type): boolean {
506
+ return (
507
+ type.kind === 'number' ||
508
+ type.kind === 'boolean' ||
509
+ type.kind === 'undefined' ||
510
+ (isBoxed(type) && isNumberish(type.of))
511
+ )
512
+ }
513
+
514
+ function domainOf(type: Type): readonly [number, number] | undefined {
515
+ if (type.kind !== 'number') {
516
+ return undefined
517
+ }
518
+ if (type.domain) {
519
+ return type.domain
520
+ }
521
+ return type.values?.length
522
+ ? [Math.min(...type.values), Math.max(...type.values)]
523
+ : undefined
524
+ }
525
+
526
+ function hull(
527
+ a: readonly [number, number] | undefined,
528
+ b: readonly [number, number] | undefined
529
+ ): readonly [number, number] | undefined {
530
+ return a && b ? [Math.min(a[0], b[0]), Math.max(a[1], b[1])] : undefined
531
+ }
532
+
533
+ function mergeValues<T>(a?: readonly T[], b?: readonly T[]) {
534
+ if (!a || !b) {
535
+ return undefined
536
+ }
537
+ const merged = [...new Set([...a, ...b])]
538
+ return merged.length <= MAX_VALUES ? merged : undefined
539
+ }
540
+
541
+ function sameField(a: FromField, b: FromField) {
542
+ return a.field === b.field ? a.field : undefined
543
+ }
544
+
545
+ function merge(a: Type, b: Type): Type | undefined {
546
+ if (a.kind === 'number' && b.kind === 'number') {
547
+ const values = mergeValues(a.values, b.values)
548
+ const domain = hull(domainOf(a), domainOf(b))
549
+ const field = sameField(a, b)
550
+ return {
551
+ kind: 'number',
552
+ ...(values && { values }),
553
+ ...(domain && !values && { domain }),
554
+ ...(field && { field })
555
+ }
556
+ }
557
+ if (a.kind === 'string' && b.kind === 'string') {
558
+ const values = mergeValues(a.values, b.values)
559
+ const field = sameField(a, b)
560
+ return {
561
+ kind: 'string',
562
+ ...(values && { values }),
563
+ ...(field && { field })
564
+ }
565
+ }
566
+ if (a.kind === 'boolean' && b.kind === 'boolean') {
567
+ return a.field === b.field ? a : BOOLEAN
568
+ }
569
+ if (a.kind === 'list' && b.kind === 'list') {
570
+ return {
571
+ kind: 'list',
572
+ of: union(a.of, b.of),
573
+ cardinality: a.cardinality === b.cardinality ? a.cardinality : 'many'
574
+ }
575
+ }
576
+ if (a.kind === 'record' && b.kind === 'record') {
577
+ return a === b ? a : { kind: 'record', fields: new Map(), open: true }
578
+ }
579
+ return undefined
580
+ }
581
+
582
+ function members(type: Type): readonly Type[] {
583
+ return type.kind === 'union' ? type.of : [type]
584
+ }
585
+
586
+ export function union(a: Type, b: Type): Type {
587
+ if (a.kind === 'undefined') {
588
+ return b
589
+ }
590
+ if (b.kind === 'undefined' || a === b) {
591
+ return a
592
+ }
593
+ if (a.kind === 'unknown' || b.kind === 'unknown') {
594
+ return UNKNOWN
595
+ }
596
+ const out: Type[] = [...members(a)]
597
+ for (const type of members(b)) {
598
+ const index = out.findIndex((existing) => existing.kind === type.kind)
599
+ const merged = index === -1 ? undefined : merge(out[index]!, type)
600
+ if (merged) {
601
+ out[index] = merged
602
+ } else {
603
+ out.push(type)
604
+ }
605
+ }
606
+ return out.length === 1 ? out[0]! : { kind: 'union', of: out }
607
+ }
608
+
609
+ function unionAll(types: Iterable<Type>) {
610
+ let out = UNDEFINED
611
+ for (const type of types) {
612
+ out = union(out, type)
613
+ }
614
+ return out
615
+ }
616
+
617
+ function truthyPart(type: Type): Type {
618
+ switch (type.kind) {
619
+ case 'undefined': {
620
+ return UNDEFINED
621
+ }
622
+ case 'string': {
623
+ return type.values
624
+ ? { ...type, values: type.values.filter(Boolean) }
625
+ : type
626
+ }
627
+ case 'number': {
628
+ return type.values
629
+ ? { ...type, values: type.values.filter(Boolean) }
630
+ : type
631
+ }
632
+ case 'union': {
633
+ return unionAll(type.of.map(truthyPart))
634
+ }
635
+ default: {
636
+ return type
637
+ }
638
+ }
639
+ }
640
+
641
+ function falsyPart(type: Type): Type {
642
+ switch (type.kind) {
643
+ case 'string': {
644
+ return { kind: 'string', values: [''] }
645
+ }
646
+ case 'number': {
647
+ return { kind: 'number', values: [0] }
648
+ }
649
+ case 'boolean':
650
+ case 'unknown': {
651
+ return type
652
+ }
653
+ case 'union': {
654
+ return unionAll(type.of.map(falsyPart))
655
+ }
656
+ default: {
657
+ return UNDEFINED
658
+ }
659
+ }
660
+ }
661
+
662
+ function arithmetic(
663
+ operator: string,
664
+ left: Type,
665
+ right: Type
666
+ ): OfKind<'number'> {
667
+ const a = domainOf(left)
668
+ const b = domainOf(right)
669
+ const combine: ((x: number, y: number) => number) | undefined =
670
+ operator === '+'
671
+ ? (x, y) => x + y
672
+ : operator === '-'
673
+ ? (x, y) => x - y
674
+ : operator === '*'
675
+ ? (x, y) => x * y
676
+ : operator === '/' && b && (b[0] > 0 || b[1] < 0)
677
+ ? (x, y) => x / y
678
+ : undefined
679
+ if (!combine) {
680
+ return { kind: 'number' }
681
+ }
682
+ const leftValues = left.kind === 'number' ? left.values : undefined
683
+ const rightValues = right.kind === 'number' ? right.values : undefined
684
+ if (
685
+ leftValues &&
686
+ rightValues &&
687
+ leftValues.length * rightValues.length <= MAX_VALUES
688
+ ) {
689
+ return {
690
+ kind: 'number',
691
+ values: [
692
+ ...new Set(
693
+ leftValues.flatMap((x) => rightValues.map((y) => combine(x, y)))
694
+ )
695
+ ]
696
+ }
697
+ }
698
+ if (!a || !b) {
699
+ return { kind: 'number' }
700
+ }
701
+ const corners = [
702
+ combine(a[0], b[0]),
703
+ combine(a[0], b[1]),
704
+ combine(a[1], b[0]),
705
+ combine(a[1], b[1])
706
+ ]
707
+ return {
708
+ kind: 'number',
709
+ domain: [Math.min(...corners), Math.max(...corners)]
710
+ }
711
+ }
712
+
713
+ function textValues(type: Type) {
714
+ return type.kind === 'string' || type.kind === 'number'
715
+ ? type.values?.map(String)
716
+ : undefined
717
+ }
718
+
719
+ /**
720
+ * `+` with text on either side joins as JavaScript joins, rather than pairing
721
+ * list values, so `REF + '>' + ALT` is one string.
722
+ */
723
+ function joined(left: Type, right: Type): Type {
724
+ const a = textValues(left)
725
+ const b = textValues(right)
726
+ return a && b && a.length * b.length <= MAX_VALUES
727
+ ? { kind: 'string', values: a.flatMap((x) => b.map((y) => x + y)) }
728
+ : STRING
729
+ }
730
+
731
+ const PASSES_INDEX = new Set(['map', 'filter', 'any', 'all', 'count', 'find'])
732
+
733
+ /** The params of a lambda that a function walks over a list's items. */
734
+ function lambdaParams(name: string, item: Type): Type[] {
735
+ switch (name) {
736
+ case 'sort': {
737
+ return [item, item]
738
+ }
739
+ case 'reduce': {
740
+ return [UNKNOWN, item]
741
+ }
742
+ default: {
743
+ return PASSES_INDEX.has(name) ? [item, NUMBER] : [item]
744
+ }
745
+ }
746
+ }
747
+
748
+ function negate(type: Type): Type {
749
+ switch (type.kind) {
750
+ case 'list': {
751
+ return {
752
+ kind: 'list',
753
+ of: negate(type.of),
754
+ cardinality: type.cardinality
755
+ }
756
+ }
757
+ case 'union': {
758
+ return unionAll(type.of.map(negate))
759
+ }
760
+ case 'unknown': {
761
+ return UNKNOWN
762
+ }
763
+ case 'number': {
764
+ if (type.values) {
765
+ return { kind: 'number', values: type.values.map((v) => -v) }
766
+ }
767
+ return type.domain
768
+ ? { kind: 'number', domain: [-type.domain[1], -type.domain[0]] }
769
+ : NUMBER
770
+ }
771
+ default: {
772
+ return NUMBER
773
+ }
774
+ }
775
+ }
776
+
777
+ function paramName(subject: AstNode, context: AstNode) {
778
+ const node = subject as Node
779
+ const base =
780
+ node.type === 'Identifier'
781
+ ? node.value.toLowerCase().replaceAll(/\W/g, '')
782
+ : ''
783
+ const name = isName(base) ? base : 'x'
784
+ const text = print(context)
785
+ let candidate = name
786
+ for (let i = 2; new RegExp(String.raw`\b${candidate}\b`).test(text); i++) {
787
+ candidate = `${name}${i}`
788
+ }
789
+ return candidate
790
+ }
791
+
792
+ function literalValue(node: AstNode) {
793
+ return node.type === 'Literal' ? (node as Literal).value : undefined
794
+ }
795
+
796
+ function describe(type: Type): string {
797
+ switch (type.kind) {
798
+ case 'list': {
799
+ return `a list of ${describe(type.of)}`
800
+ }
801
+ case 'record': {
802
+ return 'a record'
803
+ }
804
+ case 'number': {
805
+ return type.values?.length === 1 ? String(type.values[0]) : 'a number'
806
+ }
807
+ case 'string': {
808
+ return type.values?.length === 1 ? quote(type.values[0]!) : 'text'
809
+ }
810
+ case 'boolean': {
811
+ return type.field?.type === 'flag' ? 'a flag' : 'a boolean'
812
+ }
813
+ default: {
814
+ return type.kind
815
+ }
816
+ }
817
+ }
818
+
819
+ function literalOf(type: Type) {
820
+ if (type.kind === 'string' && type.values?.length === 1) {
821
+ return type.values[0]
822
+ }
823
+ return type.kind === 'number' && type.values?.length === 1
824
+ ? type.values[0]
825
+ : undefined
826
+ }
827
+
828
+ function holding(type: OfKind<'list'>) {
829
+ switch (type.cardinality) {
830
+ case 'perAlt': {
831
+ return 'holds one value per ALT allele'
832
+ }
833
+ case 'perAllele': {
834
+ return 'holds one value per allele, REF first'
835
+ }
836
+ case 'perGenotype': {
837
+ return 'holds one value per possible genotype'
838
+ }
839
+ case 'many': {
840
+ return 'holds any number of values'
841
+ }
842
+ default: {
843
+ return `holds ${type.cardinality} values`
844
+ }
845
+ }
846
+ }
847
+
848
+ function firstAbsentIndex(cardinality: Exclude<Cardinality, 'one'>) {
849
+ switch (cardinality) {
850
+ case 'perAlt': {
851
+ return 1
852
+ }
853
+ case 'perAllele': {
854
+ return 2
855
+ }
856
+ case 'perGenotype': {
857
+ return 3
858
+ }
859
+ case 'many': {
860
+ return Infinity
861
+ }
862
+ default: {
863
+ return cardinality
864
+ }
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Checks an expression against the fields a host's data declares and the
870
+ * functions it registers, without evaluating it, and infers what it answers.
871
+ */
872
+ export function check(
873
+ ast: AstNode | null,
874
+ { schema, functions, accessors = {}, row, env }: CheckOptions = {}
875
+ ): CheckResult {
876
+ const diagnostics: Diagnostic[] = []
877
+ const root = schema ? recordOf(schema) : UNKNOWN
878
+ const scope = new Map<string, Type>()
879
+
880
+ function report(
881
+ code: DiagnosticCode,
882
+ severity: Severity,
883
+ node: AstNode,
884
+ message: string,
885
+ suggestions?: string[]
886
+ ) {
887
+ diagnostics.push({
888
+ code,
889
+ severity,
890
+ message,
891
+ node,
892
+ ...(suggestions?.length ? { suggestions } : {})
893
+ })
894
+ }
895
+
896
+ function text(node: AstNode | undefined, name: string) {
897
+ return node ? print(node) + key(name) : name
898
+ }
899
+
900
+ function field(
901
+ subject: Type,
902
+ name: string,
903
+ node: AstNode,
904
+ subjectNode: AstNode | undefined,
905
+ spell = (candidate: string) => text(subjectNode, candidate)
906
+ ): Type {
907
+ const where = subjectNode ? print(subjectNode) : 'the row'
908
+ switch (subject.kind) {
909
+ case 'record': {
910
+ const found = subject.fields.get(name) ?? subject.each
911
+ if (found) {
912
+ return found
913
+ }
914
+ if (!subject.open) {
915
+ const near = nearest(name, subject.fields.keys())
916
+ report(
917
+ 'unknown-field',
918
+ 'warning',
919
+ node,
920
+ `${where} has no field ${name}` +
921
+ (near.length ? `; did you mean ${near.join(', ')}?` : ''),
922
+ near.map(spell)
923
+ )
924
+ }
925
+ return UNKNOWN
926
+ }
927
+ case 'list': {
928
+ if (name === 'length') {
929
+ return { kind: 'number', domain: [0, Infinity] }
930
+ }
931
+ const item = subject.of
932
+ const entriesHave =
933
+ item.kind === 'unknown' ||
934
+ (item.kind === 'record' &&
935
+ (item.open || !!item.each || item.fields.has(name)))
936
+ const suggestions: string[] = []
937
+ if (entriesHave && subjectNode && node.type === 'Identifier') {
938
+ const list = print(subjectNode)
939
+ const param = paramName(subjectNode, node)
940
+ suggestions.push(`${list}[0]${key(name)}`)
941
+ if (functions?.map) {
942
+ suggestions.push(`map(${list}, ${param} => ${param}${key(name)})`)
943
+ }
944
+ }
945
+ report(
946
+ 'dot-through-list',
947
+ 'warning',
948
+ node,
949
+ `${where} is a list, so ${key(name)} is undefined` +
950
+ (entriesHave
951
+ ? `; [0]${key(name)} reads the first entry's` +
952
+ (functions?.any ? ', and any() tests each' : '')
953
+ : ''),
954
+ suggestions
955
+ )
956
+ return UNDEFINED
957
+ }
958
+ case 'string': {
959
+ if (name === 'length') {
960
+ return { kind: 'number', domain: [0, Infinity] }
961
+ }
962
+ break
963
+ }
964
+ case 'number':
965
+ case 'boolean': {
966
+ break
967
+ }
968
+ default: {
969
+ return UNKNOWN
970
+ }
971
+ }
972
+ report(
973
+ 'unknown-field',
974
+ 'warning',
975
+ node,
976
+ `${where} is ${describe(subject)}, which has no field ${name}`
977
+ )
978
+ return UNKNOWN
979
+ }
980
+
981
+ function index(
982
+ subject: Type,
983
+ at: number,
984
+ node: AstNode,
985
+ subjectNode: AstNode
986
+ ): Type {
987
+ const where = print(subjectNode)
988
+ switch (subject.kind) {
989
+ case 'list': {
990
+ const absent = firstAbsentIndex(subject.cardinality)
991
+ if (at >= absent) {
992
+ const fixed = typeof subject.cardinality === 'number'
993
+ report(
994
+ 'index-range',
995
+ 'warning',
996
+ node,
997
+ fixed
998
+ ? `${where} ${holding(subject)}, so [${at}] is always undefined`
999
+ : `${where} ${holding(subject)}, so [${at}] is undefined on every biallelic record`,
1000
+ subject.cardinality === 'perAlt' ? [`${where}[0]`] : []
1001
+ )
1002
+ }
1003
+ return subject.of
1004
+ }
1005
+ case 'record': {
1006
+ return field(subject, String(at), node, subjectNode)
1007
+ }
1008
+ case 'string': {
1009
+ report(
1010
+ 'index-scalar',
1011
+ 'warning',
1012
+ node,
1013
+ `${where} holds one string, so [${at}] reads a single character`,
1014
+ [where]
1015
+ )
1016
+ return STRING
1017
+ }
1018
+ case 'number':
1019
+ case 'boolean': {
1020
+ report(
1021
+ 'index-scalar',
1022
+ 'warning',
1023
+ node,
1024
+ `${where} holds one value, so [${at}] reads nothing`,
1025
+ [where]
1026
+ )
1027
+ return UNDEFINED
1028
+ }
1029
+ default: {
1030
+ return UNKNOWN
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ function chain(node: Identifier): { base: AstNode; names: string[] } {
1036
+ const names = [node.value]
1037
+ let base: AstNode = node
1038
+ while (base.type === 'Identifier' && (base as Identifier).from) {
1039
+ base = (base as Identifier).from!
1040
+ if (base.type === 'Identifier') {
1041
+ names.unshift((base as Identifier).value)
1042
+ }
1043
+ }
1044
+ return { base, names }
1045
+ }
1046
+
1047
+ function dottedKey(node: Identifier): Type | undefined {
1048
+ if (!node.from) {
1049
+ return undefined
1050
+ }
1051
+ const { base, names } = chain(node)
1052
+ if ((base as Identifier).from) {
1053
+ return undefined
1054
+ }
1055
+ let type = variable(base as Identifier, false)
1056
+ let subjectNode: AstNode = base
1057
+ for (let i = 1; i < names.length; i++) {
1058
+ const record = type.kind === 'record' ? type : undefined
1059
+ if (!record) {
1060
+ return undefined
1061
+ }
1062
+ for (let j = names.length; j > i + 1; j--) {
1063
+ const whole = names.slice(i, j).join('.')
1064
+ const step = record.fields.get(names[i]!)
1065
+ const stepReaches =
1066
+ step?.kind === 'record' &&
1067
+ (step.fields.has(names[i + 1]!) || !!step.each)
1068
+ if (record.fields.has(whole) && !stepReaches) {
1069
+ const fixed = print(subjectNode) + key(whole)
1070
+ report(
1071
+ 'dotted-key',
1072
+ 'warning',
1073
+ node,
1074
+ `${whole} is one field of ${print(subjectNode)}; jexl reads ${names.slice(i, j).join(' then ')}`,
1075
+ [[fixed, ...names.slice(j).map((name) => key(name))].join('')]
1076
+ )
1077
+ let resolved = record.fields.get(whole)!
1078
+ for (const name of names.slice(j)) {
1079
+ resolved = field(resolved, name, node, undefined)
1080
+ }
1081
+ return resolved
1082
+ }
1083
+ }
1084
+ const next = record.fields.get(names[i]!)
1085
+ if (!next) {
1086
+ return undefined
1087
+ }
1088
+ type = next
1089
+ subjectNode = {
1090
+ type: 'Identifier',
1091
+ value: names[i]!,
1092
+ from: subjectNode
1093
+ } as Identifier
1094
+ }
1095
+ return undefined
1096
+ }
1097
+
1098
+ function variable(node: Identifier, diagnose = true): Type {
1099
+ const name = node.value
1100
+ const local = scope.get(name)
1101
+ if (local) {
1102
+ return local
1103
+ }
1104
+ if (name === row) {
1105
+ return root
1106
+ }
1107
+ if (env && Object.hasOwn(env, name)) {
1108
+ return env[name]!
1109
+ }
1110
+ if (name === 'undefined') {
1111
+ return UNDEFINED
1112
+ }
1113
+ if (row === undefined && root.kind === 'record') {
1114
+ return diagnose
1115
+ ? field(root, name, node, undefined)
1116
+ : (root.fields.get(name) ?? UNKNOWN)
1117
+ }
1118
+ if (env && diagnose) {
1119
+ const known = [
1120
+ ...(row ? [row] : []),
1121
+ ...Object.keys(env),
1122
+ ...scope.keys()
1123
+ ]
1124
+ const near = nearest(name, known)
1125
+ report(
1126
+ 'unknown-variable',
1127
+ 'error',
1128
+ node,
1129
+ `${name} is not defined here` +
1130
+ (near.length ? `; did you mean ${near.join(', ')}?` : ''),
1131
+ near
1132
+ )
1133
+ }
1134
+ return UNKNOWN
1135
+ }
1136
+
1137
+ function hyphenated(node: AstNodeUnion): Type | undefined {
1138
+ if (
1139
+ node.type !== 'BinaryExpression' ||
1140
+ node.operator !== '-' ||
1141
+ node.left.type !== 'Identifier' ||
1142
+ node.right?.type !== 'Identifier' ||
1143
+ (node.right as Identifier).from
1144
+ ) {
1145
+ return undefined
1146
+ }
1147
+ const left = node.left as Identifier
1148
+ const reported = diagnostics.length
1149
+ const subject = left.from ? infer(left.from) : root
1150
+ diagnostics.length = reported
1151
+ if (subject.kind !== 'record' || subject.fields.has(left.value)) {
1152
+ return undefined
1153
+ }
1154
+ const whole = `${left.value}-${(node.right as Identifier).value}`
1155
+ const found = subject.fields.get(whole)
1156
+ if (!found) {
1157
+ return undefined
1158
+ }
1159
+ report(
1160
+ 'unknown-field',
1161
+ 'warning',
1162
+ node,
1163
+ `${print(node)} subtracts; ${whole} is one field`,
1164
+ [text(left.from, whole)]
1165
+ )
1166
+ return found
1167
+ }
1168
+
1169
+ function memberFor(
1170
+ element: Type,
1171
+ value: Type,
1172
+ valueNode: AstNode,
1173
+ listNode: AstNode,
1174
+ node: AstNode
1175
+ ) {
1176
+ const literal = literalOf(value)
1177
+ if (literal === undefined) {
1178
+ return
1179
+ }
1180
+ if (element.kind === 'record' && typeof literal === 'string') {
1181
+ const holder = [...element.fields].find(([, type]) => {
1182
+ const scalar = type.kind === 'list' ? type.of : type
1183
+ return scalar.kind === 'string' && scalar.values?.includes(literal)
1184
+ })
1185
+ report(
1186
+ 'never-equal',
1187
+ 'warning',
1188
+ node,
1189
+ `${print(listNode)} holds records, so no entry equals ${quote(literal)}` +
1190
+ (holder ? `; it is a value of their ${holder[0]} field` : ''),
1191
+ holder && functions?.any
1192
+ ? [
1193
+ `any(${print(listNode)}, ${paramName(listNode, node)} => ${print(valueNode)} in ${paramName(listNode, node)}${key(holder[0])})`
1194
+ ]
1195
+ : []
1196
+ )
1197
+ return
1198
+ }
1199
+ compareLiteral(element, literal, node)
1200
+ }
1201
+
1202
+ function compareLiteral(type: Type, literal: string | number, node: AstNode) {
1203
+ if (
1204
+ type.kind === 'number' &&
1205
+ typeof literal === 'string' &&
1206
+ Number.isNaN(Number(literal))
1207
+ ) {
1208
+ report(
1209
+ 'never-equal',
1210
+ 'warning',
1211
+ node,
1212
+ `${quote(literal)} is not a number, so it never equals ${describe(type)}`
1213
+ )
1214
+ } else if (type.kind === 'boolean' && typeof literal === 'string') {
1215
+ report(
1216
+ 'never-equal',
1217
+ 'warning',
1218
+ node,
1219
+ `${describe(type)} never equals the text ${quote(literal)}`,
1220
+ literal === 'true' || literal === 'false' ? [literal] : []
1221
+ )
1222
+ } else if (
1223
+ type.kind === 'string' &&
1224
+ type.field?.categories &&
1225
+ typeof literal === 'string' &&
1226
+ !type.field.categories.includes(literal)
1227
+ ) {
1228
+ const near = nearest(literal, type.field.categories)
1229
+ report(
1230
+ 'unknown-category',
1231
+ 'warning',
1232
+ node,
1233
+ `${quote(literal)} is not one of the values the header declares for ${type.field.path.join('.')}` +
1234
+ (near.length ? `; did you mean ${near.map(quote).join(', ')}?` : ''),
1235
+ near.map(quote)
1236
+ )
1237
+ } else if (type.kind === 'record') {
1238
+ report(
1239
+ 'never-equal',
1240
+ 'warning',
1241
+ node,
1242
+ `a record never equals ${quote(String(literal))}`
1243
+ )
1244
+ }
1245
+ }
1246
+
1247
+ function binary(
1248
+ node: Extract<AstNodeUnion, { type: 'BinaryExpression' }>
1249
+ ): Type {
1250
+ const { operator } = node
1251
+ const rightNode = node.right!
1252
+ if (operator === '&&' || operator === '||' || operator === '??') {
1253
+ const left = infer(node.left)
1254
+ const right = infer(rightNode)
1255
+ if (operator === '&&') {
1256
+ return union(falsyPart(left), right)
1257
+ }
1258
+ return union(operator === '||' ? truthyPart(left) : left, right)
1259
+ }
1260
+ const hyphen = hyphenated(node)
1261
+ if (hyphen) {
1262
+ return hyphen
1263
+ }
1264
+ const left = infer(node.left)
1265
+ const right = infer(rightNode)
1266
+
1267
+ if (operator === '~' || operator === '!~') {
1268
+ const pattern = literalOf(right)
1269
+ if (typeof pattern === 'string' && !isPattern(pattern)) {
1270
+ report(
1271
+ 'invalid-pattern',
1272
+ 'error',
1273
+ node,
1274
+ `${quote(pattern)} is not a valid regular expression`
1275
+ )
1276
+ }
1277
+ return BOOLEAN
1278
+ }
1279
+
1280
+ const leftItem = left.kind === 'list' ? left.of : left
1281
+ const rightItem = right.kind === 'list' ? right.of : right
1282
+ if (
1283
+ operator === '+' &&
1284
+ (leftItem.kind === 'string' || rightItem.kind === 'string')
1285
+ ) {
1286
+ return joined(left, right)
1287
+ }
1288
+
1289
+ const lists = [left, right].filter(
1290
+ (type): type is OfKind<'list'> => type.kind === 'list'
1291
+ )
1292
+ const [first, second] = lists
1293
+ if (
1294
+ first &&
1295
+ second &&
1296
+ (ARITHMETIC.has(operator) || operator === '+') &&
1297
+ !pairs(first.cardinality, second.cardinality)
1298
+ ) {
1299
+ report(
1300
+ 'list-lengths',
1301
+ 'warning',
1302
+ node,
1303
+ `${print(node.left)} ${holding(first)} and ${print(rightNode)} ${holding(second)}, so ${operator} pairs them only where both hold one value`
1304
+ )
1305
+ }
1306
+ const eachOf = (item: Type): Type =>
1307
+ first ? { kind: 'list', of: item, cardinality: first.cardinality } : item
1308
+
1309
+ if (operator === '+') {
1310
+ return isNumberish(leftItem) && isNumberish(rightItem)
1311
+ ? eachOf(arithmetic('+', leftItem, rightItem))
1312
+ : UNKNOWN
1313
+ }
1314
+
1315
+ if (ARITHMETIC.has(operator) || ORDER.has(operator)) {
1316
+ for (const [type, typeNode, other] of [
1317
+ [leftItem, node.left, rightItem],
1318
+ [rightItem, rightNode, leftItem]
1319
+ ] as const) {
1320
+ const numericOther = ARITHMETIC.has(operator) || isNumberish(other)
1321
+ if (type.kind === 'string' && type.field && numericOther) {
1322
+ report(
1323
+ 'string-numeric',
1324
+ 'warning',
1325
+ node,
1326
+ `${print(typeNode)} is text, so ${operator} converts it to a number, ${ORDER.has(operator) ? 'answering false' : 'giving NaN'} wherever it is not one`,
1327
+ functions?.parseFloat
1328
+ ? [
1329
+ print(node).replace(
1330
+ print(typeNode),
1331
+ `parseFloat(${print(typeNode)})`
1332
+ )
1333
+ ]
1334
+ : []
1335
+ )
1336
+ }
1337
+ }
1338
+ return ORDER.has(operator)
1339
+ ? BOOLEAN
1340
+ : eachOf(arithmetic(operator, leftItem, rightItem))
1341
+ }
1342
+
1343
+ if (EQUALITY.has(operator)) {
1344
+ const leftLiteral = literalOf(leftItem)
1345
+ const rightLiteral = literalOf(rightItem)
1346
+ if (rightLiteral !== undefined && node.right?.type === 'Literal') {
1347
+ compareLiteral(leftItem, rightLiteral, node)
1348
+ } else if (leftLiteral !== undefined && node.left.type === 'Literal') {
1349
+ compareLiteral(rightItem, leftLiteral, node)
1350
+ }
1351
+ return BOOLEAN
1352
+ }
1353
+
1354
+ if (operator === 'in') {
1355
+ if (right.kind === 'list') {
1356
+ memberFor(right.of, leftItem, node.left, rightNode, node)
1357
+ } else if (right.kind === 'number' || right.kind === 'boolean') {
1358
+ report(
1359
+ 'never-equal',
1360
+ 'warning',
1361
+ node,
1362
+ `in tests a list, text, a Set, a Map or an object's keys, and ${print(rightNode)} is ${describe(right)}`
1363
+ )
1364
+ }
1365
+ return BOOLEAN
1366
+ }
1367
+ return UNKNOWN
1368
+ }
1369
+
1370
+ function argumentProblem(param: ParamType, type: Type): string | undefined {
1371
+ switch (param) {
1372
+ case 'any':
1373
+ case 'boolean':
1374
+ case 'member':
1375
+ case 'lambda': {
1376
+ return undefined
1377
+ }
1378
+ case 'number': {
1379
+ if (type.kind === 'string' && type.field) {
1380
+ return 'is text'
1381
+ }
1382
+ return isMulti(type) || type.kind === 'record'
1383
+ ? `is ${describe(type)}`
1384
+ : undefined
1385
+ }
1386
+ case 'string': {
1387
+ return isMulti(type) || type.kind === 'record'
1388
+ ? `is ${describe(type)}`
1389
+ : undefined
1390
+ }
1391
+ case 'list': {
1392
+ return ['number', 'string', 'boolean', 'record'].includes(type.kind)
1393
+ ? `is ${describe(type)}`
1394
+ : undefined
1395
+ }
1396
+ case 'record': {
1397
+ return ['number', 'string', 'boolean', 'list'].includes(type.kind)
1398
+ ? `is ${describe(type)}`
1399
+ : undefined
1400
+ }
1401
+ }
1402
+ }
1403
+
1404
+ function lambda(node: Lambda, types: readonly Type[] = []): Type {
1405
+ const saved = node.params.map((name) => [name, scope.get(name)] as const)
1406
+ node.params.forEach((name, i) => {
1407
+ scope.set(name, types[i] ?? UNKNOWN)
1408
+ })
1409
+ const returns = infer(node.body)
1410
+ for (const [name, previous] of saved) {
1411
+ if (previous) {
1412
+ scope.set(name, previous)
1413
+ } else {
1414
+ scope.delete(name)
1415
+ }
1416
+ }
1417
+ return { kind: 'lambda', returns }
1418
+ }
1419
+
1420
+ function call(node: FunctionCall): Type {
1421
+ const { name, args } = node
1422
+ const prefix = Object.hasOwn(accessors, name) ? accessors[name] : undefined
1423
+ if (prefix && args[0]) {
1424
+ const [subjectArg, ...keyArgs] = args as [AstNode, ...AstNode[]]
1425
+ let type = infer(subjectArg)
1426
+ let subjectNode = subjectArg
1427
+ const step = (name: string, at: AstNode, spell?: typeof quote) => {
1428
+ type = field(type, name, at, subjectNode, spell)
1429
+ subjectNode = {
1430
+ type: 'Identifier',
1431
+ value: name,
1432
+ from: subjectNode
1433
+ } as Identifier
1434
+ }
1435
+ for (const name of prefix) {
1436
+ step(String(name), node)
1437
+ }
1438
+ for (const arg of keyArgs) {
1439
+ const name = literalValue(arg)
1440
+ if (typeof name !== 'string' && typeof name !== 'number') {
1441
+ keyArgs.forEach(infer)
1442
+ return UNKNOWN
1443
+ }
1444
+ step(String(name), arg, quote)
1445
+ }
1446
+ return type
1447
+ }
1448
+ const signature =
1449
+ functions && Object.hasOwn(functions, name) ? functions[name] : undefined
1450
+ if (!signature) {
1451
+ args.forEach(infer)
1452
+ if (functions) {
1453
+ const near = nearest(name, Object.keys(functions))
1454
+ report(
1455
+ 'unknown-function',
1456
+ 'error',
1457
+ node,
1458
+ `no function ${name}` +
1459
+ (near.length ? `; did you mean ${near.join(', ')}?` : ''),
1460
+ near.map((candidate) =>
1461
+ print({ ...node, name: candidate } as FunctionCall)
1462
+ )
1463
+ )
1464
+ }
1465
+ return UNKNOWN
1466
+ }
1467
+ const { params, optional = 0, variadic = false } = signature
1468
+ const least = params.length - optional - (variadic ? 1 : 0)
1469
+ const most = variadic ? Infinity : params.length
1470
+ if (args.length < least || args.length > most) {
1471
+ report(
1472
+ 'arity',
1473
+ 'error',
1474
+ node,
1475
+ `${name} takes ${least === most ? least : most === Infinity ? `at least ${least}` : `${least} to ${most}`} argument${most === 1 ? '' : 's'}, not ${args.length}`
1476
+ )
1477
+ }
1478
+ const types: Type[] = []
1479
+ let lastCollection: Type | undefined
1480
+ let lastCollectionNode: AstNode | undefined
1481
+ for (const [i, arg] of args.entries()) {
1482
+ const declared = params[Math.min(i, params.length - 1)]
1483
+ if (declared === undefined || (!variadic && i >= params.length)) {
1484
+ types.push(infer(arg))
1485
+ continue
1486
+ }
1487
+ const accepted: readonly ParamType[] =
1488
+ typeof declared === 'string' ? [declared] : declared
1489
+ if ((arg as Node).type === 'Lambda') {
1490
+ const item =
1491
+ lastCollection?.kind === 'list' ? lastCollection.of : UNKNOWN
1492
+ types.push(lambda(arg as Lambda, lambdaParams(name, item)))
1493
+ continue
1494
+ }
1495
+ const type = infer(arg)
1496
+ types.push(type)
1497
+ if (accepted.includes('lambda')) {
1498
+ report(
1499
+ 'argument-type',
1500
+ 'error',
1501
+ arg,
1502
+ `${name} expects a function such as x => …, not ${print(arg)}`
1503
+ )
1504
+ continue
1505
+ }
1506
+ if (accepted.includes('member') && lastCollection && lastCollectionNode) {
1507
+ if (lastCollection.kind === 'list') {
1508
+ memberFor(lastCollection.of, type, arg, lastCollectionNode, node)
1509
+ }
1510
+ continue
1511
+ }
1512
+ const problems = accepted.map((param) => argumentProblem(param, type))
1513
+ if (problems.every(Boolean)) {
1514
+ report(
1515
+ isMulti(type)
1516
+ ? 'list-operand'
1517
+ : type.kind === 'string'
1518
+ ? 'string-numeric'
1519
+ : 'argument-type',
1520
+ 'warning',
1521
+ arg,
1522
+ `${name} expects ${accepted.join(' or ')} as argument ${i + 1}, but ${print(arg)} ${problems[0]}`
1523
+ )
1524
+ }
1525
+ if (accepted.includes('list')) {
1526
+ lastCollection = type
1527
+ lastCollectionNode = arg
1528
+ }
1529
+ }
1530
+ const { returns } = signature
1531
+ if (typeof returns === 'function') {
1532
+ return returns(types)
1533
+ }
1534
+ if (typeof returns === 'string') {
1535
+ return { kind: returns }
1536
+ }
1537
+ return returns ?? UNKNOWN
1538
+ }
1539
+
1540
+ function infer(ast: AstNode): Type {
1541
+ const node = ast as Node
1542
+ switch (node.type) {
1543
+ case 'Literal': {
1544
+ const { value } = node
1545
+ return typeof value === 'number'
1546
+ ? { kind: 'number', values: [value] }
1547
+ : typeof value === 'string'
1548
+ ? { kind: 'string', values: [value] }
1549
+ : value === null
1550
+ ? UNDEFINED
1551
+ : BOOLEAN
1552
+ }
1553
+ case 'Identifier': {
1554
+ if (!node.from) {
1555
+ return variable(node)
1556
+ }
1557
+ const dotted = dottedKey(node)
1558
+ if (dotted) {
1559
+ return dotted
1560
+ }
1561
+ return field(infer(node.from), node.value, node, node.from)
1562
+ }
1563
+ case 'FilterExpression': {
1564
+ const subject = infer(node.subject)
1565
+ const at = literalValue(node.expr)
1566
+ if (typeof at === 'number') {
1567
+ return index(subject, at, node, node.subject)
1568
+ }
1569
+ if (typeof at === 'string') {
1570
+ if (subject.kind === 'list' && at !== 'length') {
1571
+ report(
1572
+ 'unknown-field',
1573
+ 'warning',
1574
+ node,
1575
+ `${print(node.subject)} is ${describe(subject)}, so [${quote(at)}] reads nothing`
1576
+ )
1577
+ return UNDEFINED
1578
+ }
1579
+ return field(subject, at, node, node.subject)
1580
+ }
1581
+ const by = infer(node.expr)
1582
+ if (isMulti(by)) {
1583
+ report(
1584
+ 'list-operand',
1585
+ 'warning',
1586
+ node,
1587
+ `${print(node.expr)} ${holding(by)}, so indexing by it finds an entry only when there is exactly one`
1588
+ )
1589
+ }
1590
+ if (subject.kind === 'record') {
1591
+ return unionAll([
1592
+ ...subject.fields.values(),
1593
+ subject.each ?? UNDEFINED
1594
+ ])
1595
+ }
1596
+ return subject.kind === 'list' ? subject.of : UNKNOWN
1597
+ }
1598
+ case 'BinaryExpression': {
1599
+ return binary(node)
1600
+ }
1601
+ case 'UnaryExpression': {
1602
+ const right = infer(node.right!)
1603
+ if (node.operator === '!') {
1604
+ return BOOLEAN
1605
+ }
1606
+ if (node.operator === '-') {
1607
+ return negate(right)
1608
+ }
1609
+ return UNKNOWN
1610
+ }
1611
+ case 'ConditionalExpression': {
1612
+ const test = infer(node.test)
1613
+ const consequent = node.consequent
1614
+ ? infer(node.consequent)
1615
+ : truthyPart(test)
1616
+ const alternate = node.alternate ? infer(node.alternate) : UNDEFINED
1617
+ return union(consequent, alternate)
1618
+ }
1619
+ case 'FunctionCall': {
1620
+ return call(node)
1621
+ }
1622
+ case 'ArrayLiteral': {
1623
+ const items = node.value.map(infer)
1624
+ return {
1625
+ kind: 'list',
1626
+ of: unionAll(items),
1627
+ cardinality: items.length
1628
+ }
1629
+ }
1630
+ case 'ObjectLiteral': {
1631
+ return {
1632
+ kind: 'record',
1633
+ fields: new Map(
1634
+ Object.entries(node.value).map(([name, value]) => [
1635
+ name,
1636
+ infer(value)
1637
+ ])
1638
+ ),
1639
+ open: false
1640
+ }
1641
+ }
1642
+ case 'TemplateLiteral': {
1643
+ let values: string[] | undefined = ['']
1644
+ for (const part of node.parts) {
1645
+ if (part.type === 'static') {
1646
+ values = values?.map((value) => value + part.value)
1647
+ } else {
1648
+ const options = textValues(infer(part.value))
1649
+ values =
1650
+ values && options && values.length * options.length <= MAX_VALUES
1651
+ ? values.flatMap((value) =>
1652
+ options.map((option) => value + option)
1653
+ )
1654
+ : undefined
1655
+ }
1656
+ }
1657
+ return values ? { kind: 'string', values } : STRING
1658
+ }
1659
+ case 'SequenceExpression': {
1660
+ let last: Type = UNDEFINED
1661
+ for (const expr of node.expressions) {
1662
+ last = infer(expr)
1663
+ }
1664
+ return last
1665
+ }
1666
+ case 'AssignmentExpression': {
1667
+ const value = infer(node.right!)
1668
+ scope.set(node.left.value, value)
1669
+ return value
1670
+ }
1671
+ case 'Lambda': {
1672
+ return lambda(node)
1673
+ }
1674
+ default: {
1675
+ throw new Error(`Corrupt AST: unknown node type '${ast.type}'`)
1676
+ }
1677
+ }
1678
+ }
1679
+
1680
+ const type = ast ? infer(ast) : UNDEFINED
1681
+ return { diagnostics, type }
1682
+ }