@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.
- package/README.md +163 -20
- package/dist/Expression.d.ts +16 -0
- package/dist/Expression.js +28 -4
- package/dist/Expression.js.map +1 -1
- package/dist/Jexl.d.ts +20 -8
- package/dist/Jexl.js +34 -15
- package/dist/Jexl.js.map +1 -1
- package/dist/Lexer.d.ts +4 -3
- package/dist/Lexer.js +29 -21
- package/dist/Lexer.js.map +1 -1
- package/dist/analyze.d.ts +84 -0
- package/dist/analyze.js +259 -0
- package/dist/analyze.js.map +1 -0
- package/dist/check.d.ts +109 -0
- package/dist/check.js +1267 -0
- package/dist/check.js.map +1 -0
- package/dist/collections.d.ts +3 -0
- package/dist/collections.js +140 -0
- package/dist/collections.js.map +1 -0
- package/dist/conditions.d.ts +62 -0
- package/dist/conditions.js +207 -0
- package/dist/conditions.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/compile.d.ts +10 -7
- package/dist/evaluator/compile.js +292 -99
- package/dist/evaluator/compile.js.map +1 -1
- package/dist/grammar.d.ts +22 -9
- package/dist/grammar.js +83 -55
- package/dist/grammar.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +16 -1
- package/dist/index.js.map +1 -1
- package/dist/operators.d.ts +34 -0
- package/dist/operators.js +131 -0
- package/dist/operators.js.map +1 -0
- package/dist/parser/Parser.d.ts +48 -91
- package/dist/parser/Parser.js +347 -221
- package/dist/parser/Parser.js.map +1 -1
- package/dist/types.d.ts +11 -5
- package/esm/Expression.d.ts +16 -0
- package/esm/Expression.js +28 -4
- package/esm/Expression.js.map +1 -1
- package/esm/Jexl.d.ts +20 -8
- package/esm/Jexl.js +34 -15
- package/esm/Jexl.js.map +1 -1
- package/esm/Lexer.d.ts +4 -3
- package/esm/Lexer.js +29 -21
- package/esm/Lexer.js.map +1 -1
- package/esm/analyze.d.ts +84 -0
- package/esm/analyze.js +256 -0
- package/esm/analyze.js.map +1 -0
- package/esm/check.d.ts +109 -0
- package/esm/check.js +1260 -0
- package/esm/check.js.map +1 -0
- package/esm/collections.d.ts +3 -0
- package/esm/collections.js +137 -0
- package/esm/collections.js.map +1 -0
- package/esm/conditions.d.ts +62 -0
- package/esm/conditions.js +200 -0
- package/esm/conditions.js.map +1 -0
- package/esm/errors.d.ts +5 -0
- package/esm/errors.js +14 -0
- package/esm/errors.js.map +1 -0
- package/esm/evaluator/compile.d.ts +10 -7
- package/esm/evaluator/compile.js +292 -99
- package/esm/evaluator/compile.js.map +1 -1
- package/esm/grammar.d.ts +22 -9
- package/esm/grammar.js +79 -53
- package/esm/grammar.js.map +1 -1
- package/esm/index.d.ts +9 -0
- package/esm/index.js +4 -0
- package/esm/index.js.map +1 -1
- package/esm/operators.d.ts +34 -0
- package/esm/operators.js +123 -0
- package/esm/operators.js.map +1 -0
- package/esm/parser/Parser.d.ts +48 -91
- package/esm/parser/Parser.js +349 -192
- package/esm/parser/Parser.js.map +1 -1
- package/esm/types.d.ts +11 -5
- package/package.json +1 -1
- package/src/Expression.ts +34 -4
- package/src/Jexl.ts +52 -16
- package/src/Lexer.ts +32 -21
- package/src/analyze.ts +378 -0
- package/src/check.ts +1682 -0
- package/src/collections.ts +148 -0
- package/src/conditions.ts +262 -0
- package/src/errors.ts +15 -0
- package/src/evaluator/compile.ts +351 -102
- package/src/grammar.ts +137 -55
- package/src/index.ts +15 -0
- package/src/operators.ts +151 -0
- package/src/parser/Parser.ts +389 -199
- package/src/types.ts +13 -3
- package/dist/parser/handlers.d.ts +0 -115
- package/dist/parser/handlers.js +0 -352
- package/dist/parser/handlers.js.map +0 -1
- package/dist/parser/states.d.ts +0 -47
- package/dist/parser/states.js +0 -176
- package/dist/parser/states.js.map +0 -1
- package/esm/parser/handlers.d.ts +0 -115
- package/esm/parser/handlers.js +0 -328
- package/esm/parser/handlers.js.map +0 -1
- package/esm/parser/states.d.ts +0 -47
- package/esm/parser/states.js +0 -140
- package/esm/parser/states.js.map +0 -1
- package/src/parser/handlers.ts +0 -372
- 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
|
+
}
|