@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/esm/check.js
ADDED
|
@@ -0,0 +1,1260 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Jexl
|
|
3
|
+
* Copyright 2020 Tom Shawver
|
|
4
|
+
*/
|
|
5
|
+
export const UNKNOWN = { kind: 'unknown' };
|
|
6
|
+
const UNDEFINED = { kind: 'undefined' };
|
|
7
|
+
const BOOLEAN = { kind: 'boolean' };
|
|
8
|
+
const NUMBER = { kind: 'number' };
|
|
9
|
+
const STRING = { kind: 'string' };
|
|
10
|
+
const MAX_VALUES = 64;
|
|
11
|
+
const ARITHMETIC = new Set(['-', '*', '/', '//', '%', '^']);
|
|
12
|
+
const ORDER = new Set(['<', '<=', '>', '>=']);
|
|
13
|
+
const EQUALITY = new Set(['==', '!=']);
|
|
14
|
+
const PRECEDENCE = {
|
|
15
|
+
'=': 2,
|
|
16
|
+
'||': 10,
|
|
17
|
+
'??': 10,
|
|
18
|
+
'&&': 11,
|
|
19
|
+
'==': 20,
|
|
20
|
+
'!=': 20,
|
|
21
|
+
'~': 20,
|
|
22
|
+
'!~': 20,
|
|
23
|
+
'<': 20,
|
|
24
|
+
'<=': 20,
|
|
25
|
+
'>': 20,
|
|
26
|
+
'>=': 20,
|
|
27
|
+
in: 20,
|
|
28
|
+
'+': 30,
|
|
29
|
+
'-': 30,
|
|
30
|
+
'*': 40,
|
|
31
|
+
'/': 40,
|
|
32
|
+
'//': 40,
|
|
33
|
+
'%': 50,
|
|
34
|
+
'^': 50
|
|
35
|
+
};
|
|
36
|
+
const RIGHT_ASSOCIATIVE = new Set(['^']);
|
|
37
|
+
const LOGICAL = new Set(['||', '&&']);
|
|
38
|
+
// how tightly each form binds, mirroring the Parser's levels: `;`, then `=`
|
|
39
|
+
// and lambdas, then `?:`, then binary operators, then prefix operators
|
|
40
|
+
const SEQUENCE = 0;
|
|
41
|
+
const ASSIGNMENT = 1;
|
|
42
|
+
const CONDITIONAL = 2;
|
|
43
|
+
const CUSTOM_BINARY = 3;
|
|
44
|
+
const UNARY = 100;
|
|
45
|
+
const letter = String.raw `a-zA-Zа-яА-Я_À-ÖØ-öø-ÿ$`;
|
|
46
|
+
const IDENTIFIER = new RegExp(`^[${letter}][${letter}0-9]*$`);
|
|
47
|
+
const WORDS = new Set(['in', 'true', 'false', 'null']);
|
|
48
|
+
function isName(text) {
|
|
49
|
+
return IDENTIFIER.test(text) && !WORDS.has(text);
|
|
50
|
+
}
|
|
51
|
+
function key(name) {
|
|
52
|
+
return isName(name) ? `.${name}` : `[${quote(name)}]`;
|
|
53
|
+
}
|
|
54
|
+
function quote(text) {
|
|
55
|
+
return `'${text.replaceAll('\\', '\\\\').replaceAll("'", String.raw `\'`)}'`;
|
|
56
|
+
}
|
|
57
|
+
function precedenceOf(node) {
|
|
58
|
+
switch (node.type) {
|
|
59
|
+
case 'SequenceExpression': {
|
|
60
|
+
return SEQUENCE;
|
|
61
|
+
}
|
|
62
|
+
case 'AssignmentExpression':
|
|
63
|
+
case 'Lambda': {
|
|
64
|
+
return ASSIGNMENT;
|
|
65
|
+
}
|
|
66
|
+
case 'ConditionalExpression': {
|
|
67
|
+
// with no alternate, it ends only before `)`, `]`, `}`, `,`, `;` or the end
|
|
68
|
+
return node.alternate ? CONDITIONAL : SEQUENCE;
|
|
69
|
+
}
|
|
70
|
+
case 'BinaryExpression': {
|
|
71
|
+
return PRECEDENCE[node.operator] ?? CUSTOM_BINARY;
|
|
72
|
+
}
|
|
73
|
+
case 'UnaryExpression': {
|
|
74
|
+
return UNARY;
|
|
75
|
+
}
|
|
76
|
+
default: {
|
|
77
|
+
return Infinity;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function within(ast, least) {
|
|
82
|
+
const text = print(ast);
|
|
83
|
+
return precedenceOf(ast) < least ? `(${text})` : text;
|
|
84
|
+
}
|
|
85
|
+
function binaryOperand(parent, ast, left) {
|
|
86
|
+
const own = PRECEDENCE[parent.operator];
|
|
87
|
+
if (own === undefined) {
|
|
88
|
+
return within(ast, UNARY);
|
|
89
|
+
}
|
|
90
|
+
const child = ast;
|
|
91
|
+
const theirs = precedenceOf(child);
|
|
92
|
+
const chained = child.type === 'BinaryExpression' &&
|
|
93
|
+
RIGHT_ASSOCIATIVE.has(parent.operator) &&
|
|
94
|
+
RIGHT_ASSOCIATIVE.has(child.operator);
|
|
95
|
+
const mixesNullish = child.type === 'BinaryExpression' &&
|
|
96
|
+
((parent.operator === '??' && LOGICAL.has(child.operator)) ||
|
|
97
|
+
(child.operator === '??' && LOGICAL.has(parent.operator)));
|
|
98
|
+
const text = print(ast);
|
|
99
|
+
return theirs < own || (theirs === own && left === chained) || mixesNullish
|
|
100
|
+
? `(${text})`
|
|
101
|
+
: text;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Renders a tree back to expression text, for messages and suggestions. The
|
|
105
|
+
* text parses back to the same tree.
|
|
106
|
+
*/
|
|
107
|
+
export function print(ast) {
|
|
108
|
+
const node = ast;
|
|
109
|
+
switch (node.type) {
|
|
110
|
+
case 'Literal': {
|
|
111
|
+
const { value } = node;
|
|
112
|
+
return typeof value === 'string'
|
|
113
|
+
? quote(value)
|
|
114
|
+
: Object.is(value, -0)
|
|
115
|
+
? '-0'
|
|
116
|
+
: String(value);
|
|
117
|
+
}
|
|
118
|
+
case 'Identifier': {
|
|
119
|
+
return node.from
|
|
120
|
+
? within(node.from, Infinity) + key(node.value)
|
|
121
|
+
: node.value;
|
|
122
|
+
}
|
|
123
|
+
case 'FilterExpression': {
|
|
124
|
+
return `${within(node.subject, Infinity)}[${within(node.expr, ASSIGNMENT)}]`;
|
|
125
|
+
}
|
|
126
|
+
case 'BinaryExpression': {
|
|
127
|
+
return `${binaryOperand(node, node.left, true)} ${node.operator} ${binaryOperand(node, node.right, false)}`;
|
|
128
|
+
}
|
|
129
|
+
case 'UnaryExpression': {
|
|
130
|
+
const { operator } = node;
|
|
131
|
+
const right = within(node.right, UNARY);
|
|
132
|
+
// the Lexer folds a minus before a digit into the number
|
|
133
|
+
if (operator === '-' && /^\d/.test(right)) {
|
|
134
|
+
return `-(${right})`;
|
|
135
|
+
}
|
|
136
|
+
return IDENTIFIER.test(operator)
|
|
137
|
+
? `${operator} ${right}`
|
|
138
|
+
: operator + right;
|
|
139
|
+
}
|
|
140
|
+
case 'ConditionalExpression': {
|
|
141
|
+
const test = within(node.test, CUSTOM_BINARY);
|
|
142
|
+
const consequent = node.consequent
|
|
143
|
+
? ` ${within(node.consequent, ASSIGNMENT)} `
|
|
144
|
+
: '';
|
|
145
|
+
const alternate = node.alternate
|
|
146
|
+
? ` ${within(node.alternate, ASSIGNMENT)}`
|
|
147
|
+
: '';
|
|
148
|
+
return `${test} ?${consequent}:${alternate}`;
|
|
149
|
+
}
|
|
150
|
+
case 'FunctionCall': {
|
|
151
|
+
const args = node.args.map((arg) => within(arg, ASSIGNMENT));
|
|
152
|
+
return `${node.name}(${args.join(', ')})`;
|
|
153
|
+
}
|
|
154
|
+
case 'ArrayLiteral': {
|
|
155
|
+
const items = node.value.map((item) => within(item, ASSIGNMENT));
|
|
156
|
+
return `[${items.join(', ')}]`;
|
|
157
|
+
}
|
|
158
|
+
case 'ObjectLiteral': {
|
|
159
|
+
const entries = Object.entries(node.value).map(([name, value]) => `${isName(name) ? name : quote(name)}: ${within(value, ASSIGNMENT)}`);
|
|
160
|
+
return `{${entries.join(', ')}}`;
|
|
161
|
+
}
|
|
162
|
+
case 'TemplateLiteral': {
|
|
163
|
+
const parts = node.parts.map((part) => part.type === 'static'
|
|
164
|
+
? part.value
|
|
165
|
+
.replaceAll('\\', '\\\\')
|
|
166
|
+
.replaceAll('`', '\\`')
|
|
167
|
+
.replaceAll('${', '\\${')
|
|
168
|
+
: `\${${print(part.value)}}`);
|
|
169
|
+
return `\`${parts.join('')}\``;
|
|
170
|
+
}
|
|
171
|
+
case 'SequenceExpression': {
|
|
172
|
+
const { expressions } = node;
|
|
173
|
+
const text = expressions
|
|
174
|
+
.map((expr) => within(expr, ASSIGNMENT))
|
|
175
|
+
.join('; ');
|
|
176
|
+
return expressions.length === 1 ? `${text};` : text;
|
|
177
|
+
}
|
|
178
|
+
case 'AssignmentExpression': {
|
|
179
|
+
return `${node.left.value} = ${within(node.right, ASSIGNMENT)}`;
|
|
180
|
+
}
|
|
181
|
+
case 'Lambda': {
|
|
182
|
+
const params = node.params.length === 1
|
|
183
|
+
? node.params[0]
|
|
184
|
+
: `(${node.params.join(', ')})`;
|
|
185
|
+
return `${params} => ${within(node.body, ASSIGNMENT)}`;
|
|
186
|
+
}
|
|
187
|
+
default: {
|
|
188
|
+
return '?';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function distance(a, b) {
|
|
193
|
+
let before = [];
|
|
194
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
195
|
+
for (let i = 1; i <= a.length; i++) {
|
|
196
|
+
const current = [i];
|
|
197
|
+
for (let j = 1; j <= b.length; j++) {
|
|
198
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
199
|
+
if (a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
200
|
+
current[j] = Math.min(current[j], before[j - 2] + 1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
before = previous;
|
|
204
|
+
previous = current;
|
|
205
|
+
}
|
|
206
|
+
return previous[b.length];
|
|
207
|
+
}
|
|
208
|
+
function nearest(name, candidates) {
|
|
209
|
+
const lower = name.toLowerCase();
|
|
210
|
+
const limit = Math.max(1, Math.floor(name.length / 3));
|
|
211
|
+
const scored = [];
|
|
212
|
+
for (const candidate of candidates) {
|
|
213
|
+
const other = candidate.toLowerCase();
|
|
214
|
+
const score = other === lower
|
|
215
|
+
? 0
|
|
216
|
+
: other.startsWith(lower) && /^[^\w$]/.test(other.slice(lower.length))
|
|
217
|
+
? 1
|
|
218
|
+
: distance(lower, other);
|
|
219
|
+
if (score <= limit) {
|
|
220
|
+
scored.push([score, candidate]);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return scored
|
|
224
|
+
.sort(([a, x], [b, y]) => a - b || x.localeCompare(y))
|
|
225
|
+
.slice(0, 3)
|
|
226
|
+
.map(([, candidate]) => candidate);
|
|
227
|
+
}
|
|
228
|
+
function itemOf(field) {
|
|
229
|
+
switch (field.type) {
|
|
230
|
+
case 'number': {
|
|
231
|
+
return field.domain
|
|
232
|
+
? { kind: 'number', field, domain: field.domain }
|
|
233
|
+
: { kind: 'number', field };
|
|
234
|
+
}
|
|
235
|
+
case 'string': {
|
|
236
|
+
return field.categories
|
|
237
|
+
? { kind: 'string', field, values: field.categories }
|
|
238
|
+
: { kind: 'string', field };
|
|
239
|
+
}
|
|
240
|
+
case 'boolean':
|
|
241
|
+
case 'flag': {
|
|
242
|
+
return { kind: 'boolean', field };
|
|
243
|
+
}
|
|
244
|
+
case 'record': {
|
|
245
|
+
return { kind: 'record', field, fields: new Map(), open: !!field.open };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function typeOfField(field) {
|
|
250
|
+
const cardinality = field.cardinality ?? 'one';
|
|
251
|
+
const item = itemOf(field);
|
|
252
|
+
return cardinality === 'one'
|
|
253
|
+
? item
|
|
254
|
+
: { kind: 'list', of: item, cardinality, field };
|
|
255
|
+
}
|
|
256
|
+
function recordWithin(type) {
|
|
257
|
+
if (type.kind === 'record') {
|
|
258
|
+
return type;
|
|
259
|
+
}
|
|
260
|
+
return type.kind === 'list' ? recordWithin(type.of) : undefined;
|
|
261
|
+
}
|
|
262
|
+
/** The type of a row whose fields `schema` declares. */
|
|
263
|
+
export function recordOf(schema) {
|
|
264
|
+
const own = schema.find((field) => field.path.length === 0);
|
|
265
|
+
const root = {
|
|
266
|
+
kind: 'record',
|
|
267
|
+
fields: new Map(),
|
|
268
|
+
open: !!own?.open,
|
|
269
|
+
...(own && { field: own })
|
|
270
|
+
};
|
|
271
|
+
const byDepth = schema
|
|
272
|
+
.filter((field) => field.path.length > 0)
|
|
273
|
+
.toSorted((a, b) => a.path.length - b.path.length);
|
|
274
|
+
for (const field of byDepth) {
|
|
275
|
+
let parent = root;
|
|
276
|
+
for (const name of field.path.slice(0, -1)) {
|
|
277
|
+
const next = name === '*' ? parent.each : parent.fields.get(name);
|
|
278
|
+
let record = next && recordWithin(next);
|
|
279
|
+
if (!record) {
|
|
280
|
+
record = { kind: 'record', fields: new Map(), open: true };
|
|
281
|
+
if (name === '*') {
|
|
282
|
+
parent.each = record;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
parent.fields.set(name, record);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
parent = record;
|
|
289
|
+
}
|
|
290
|
+
const name = field.path.at(-1);
|
|
291
|
+
const type = typeOfField(field);
|
|
292
|
+
if (name === '*') {
|
|
293
|
+
parent.each = type;
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
parent.fields.set(name, type);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return root;
|
|
300
|
+
}
|
|
301
|
+
function isMulti(type) {
|
|
302
|
+
return type.kind === 'list' && type.cardinality !== 1;
|
|
303
|
+
}
|
|
304
|
+
function isBoxed(type) {
|
|
305
|
+
return type.kind === 'list' && type.cardinality === 1;
|
|
306
|
+
}
|
|
307
|
+
function isPattern(pattern) {
|
|
308
|
+
try {
|
|
309
|
+
new RegExp(pattern.startsWith('(?i)') ? pattern.slice(4) : pattern);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Whether lists of two cardinalities pair value by value everywhere: they do
|
|
318
|
+
* when they are the same, or when either is a single value.
|
|
319
|
+
*/
|
|
320
|
+
function pairs(a, b) {
|
|
321
|
+
return a === b || a === 1 || b === 1;
|
|
322
|
+
}
|
|
323
|
+
function isNumberish(type) {
|
|
324
|
+
return (type.kind === 'number' ||
|
|
325
|
+
type.kind === 'boolean' ||
|
|
326
|
+
type.kind === 'undefined' ||
|
|
327
|
+
(isBoxed(type) && isNumberish(type.of)));
|
|
328
|
+
}
|
|
329
|
+
function domainOf(type) {
|
|
330
|
+
if (type.kind !== 'number') {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
if (type.domain) {
|
|
334
|
+
return type.domain;
|
|
335
|
+
}
|
|
336
|
+
return type.values?.length
|
|
337
|
+
? [Math.min(...type.values), Math.max(...type.values)]
|
|
338
|
+
: undefined;
|
|
339
|
+
}
|
|
340
|
+
function hull(a, b) {
|
|
341
|
+
return a && b ? [Math.min(a[0], b[0]), Math.max(a[1], b[1])] : undefined;
|
|
342
|
+
}
|
|
343
|
+
function mergeValues(a, b) {
|
|
344
|
+
if (!a || !b) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
const merged = [...new Set([...a, ...b])];
|
|
348
|
+
return merged.length <= MAX_VALUES ? merged : undefined;
|
|
349
|
+
}
|
|
350
|
+
function sameField(a, b) {
|
|
351
|
+
return a.field === b.field ? a.field : undefined;
|
|
352
|
+
}
|
|
353
|
+
function merge(a, b) {
|
|
354
|
+
if (a.kind === 'number' && b.kind === 'number') {
|
|
355
|
+
const values = mergeValues(a.values, b.values);
|
|
356
|
+
const domain = hull(domainOf(a), domainOf(b));
|
|
357
|
+
const field = sameField(a, b);
|
|
358
|
+
return {
|
|
359
|
+
kind: 'number',
|
|
360
|
+
...(values && { values }),
|
|
361
|
+
...(domain && !values && { domain }),
|
|
362
|
+
...(field && { field })
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (a.kind === 'string' && b.kind === 'string') {
|
|
366
|
+
const values = mergeValues(a.values, b.values);
|
|
367
|
+
const field = sameField(a, b);
|
|
368
|
+
return {
|
|
369
|
+
kind: 'string',
|
|
370
|
+
...(values && { values }),
|
|
371
|
+
...(field && { field })
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (a.kind === 'boolean' && b.kind === 'boolean') {
|
|
375
|
+
return a.field === b.field ? a : BOOLEAN;
|
|
376
|
+
}
|
|
377
|
+
if (a.kind === 'list' && b.kind === 'list') {
|
|
378
|
+
return {
|
|
379
|
+
kind: 'list',
|
|
380
|
+
of: union(a.of, b.of),
|
|
381
|
+
cardinality: a.cardinality === b.cardinality ? a.cardinality : 'many'
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (a.kind === 'record' && b.kind === 'record') {
|
|
385
|
+
return a === b ? a : { kind: 'record', fields: new Map(), open: true };
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
function members(type) {
|
|
390
|
+
return type.kind === 'union' ? type.of : [type];
|
|
391
|
+
}
|
|
392
|
+
export function union(a, b) {
|
|
393
|
+
if (a.kind === 'undefined') {
|
|
394
|
+
return b;
|
|
395
|
+
}
|
|
396
|
+
if (b.kind === 'undefined' || a === b) {
|
|
397
|
+
return a;
|
|
398
|
+
}
|
|
399
|
+
if (a.kind === 'unknown' || b.kind === 'unknown') {
|
|
400
|
+
return UNKNOWN;
|
|
401
|
+
}
|
|
402
|
+
const out = [...members(a)];
|
|
403
|
+
for (const type of members(b)) {
|
|
404
|
+
const index = out.findIndex((existing) => existing.kind === type.kind);
|
|
405
|
+
const merged = index === -1 ? undefined : merge(out[index], type);
|
|
406
|
+
if (merged) {
|
|
407
|
+
out[index] = merged;
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
out.push(type);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return out.length === 1 ? out[0] : { kind: 'union', of: out };
|
|
414
|
+
}
|
|
415
|
+
function unionAll(types) {
|
|
416
|
+
let out = UNDEFINED;
|
|
417
|
+
for (const type of types) {
|
|
418
|
+
out = union(out, type);
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
function truthyPart(type) {
|
|
423
|
+
switch (type.kind) {
|
|
424
|
+
case 'undefined': {
|
|
425
|
+
return UNDEFINED;
|
|
426
|
+
}
|
|
427
|
+
case 'string': {
|
|
428
|
+
return type.values
|
|
429
|
+
? { ...type, values: type.values.filter(Boolean) }
|
|
430
|
+
: type;
|
|
431
|
+
}
|
|
432
|
+
case 'number': {
|
|
433
|
+
return type.values
|
|
434
|
+
? { ...type, values: type.values.filter(Boolean) }
|
|
435
|
+
: type;
|
|
436
|
+
}
|
|
437
|
+
case 'union': {
|
|
438
|
+
return unionAll(type.of.map(truthyPart));
|
|
439
|
+
}
|
|
440
|
+
default: {
|
|
441
|
+
return type;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function falsyPart(type) {
|
|
446
|
+
switch (type.kind) {
|
|
447
|
+
case 'string': {
|
|
448
|
+
return { kind: 'string', values: [''] };
|
|
449
|
+
}
|
|
450
|
+
case 'number': {
|
|
451
|
+
return { kind: 'number', values: [0] };
|
|
452
|
+
}
|
|
453
|
+
case 'boolean':
|
|
454
|
+
case 'unknown': {
|
|
455
|
+
return type;
|
|
456
|
+
}
|
|
457
|
+
case 'union': {
|
|
458
|
+
return unionAll(type.of.map(falsyPart));
|
|
459
|
+
}
|
|
460
|
+
default: {
|
|
461
|
+
return UNDEFINED;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function arithmetic(operator, left, right) {
|
|
466
|
+
const a = domainOf(left);
|
|
467
|
+
const b = domainOf(right);
|
|
468
|
+
const combine = operator === '+'
|
|
469
|
+
? (x, y) => x + y
|
|
470
|
+
: operator === '-'
|
|
471
|
+
? (x, y) => x - y
|
|
472
|
+
: operator === '*'
|
|
473
|
+
? (x, y) => x * y
|
|
474
|
+
: operator === '/' && b && (b[0] > 0 || b[1] < 0)
|
|
475
|
+
? (x, y) => x / y
|
|
476
|
+
: undefined;
|
|
477
|
+
if (!combine) {
|
|
478
|
+
return { kind: 'number' };
|
|
479
|
+
}
|
|
480
|
+
const leftValues = left.kind === 'number' ? left.values : undefined;
|
|
481
|
+
const rightValues = right.kind === 'number' ? right.values : undefined;
|
|
482
|
+
if (leftValues &&
|
|
483
|
+
rightValues &&
|
|
484
|
+
leftValues.length * rightValues.length <= MAX_VALUES) {
|
|
485
|
+
return {
|
|
486
|
+
kind: 'number',
|
|
487
|
+
values: [
|
|
488
|
+
...new Set(leftValues.flatMap((x) => rightValues.map((y) => combine(x, y))))
|
|
489
|
+
]
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (!a || !b) {
|
|
493
|
+
return { kind: 'number' };
|
|
494
|
+
}
|
|
495
|
+
const corners = [
|
|
496
|
+
combine(a[0], b[0]),
|
|
497
|
+
combine(a[0], b[1]),
|
|
498
|
+
combine(a[1], b[0]),
|
|
499
|
+
combine(a[1], b[1])
|
|
500
|
+
];
|
|
501
|
+
return {
|
|
502
|
+
kind: 'number',
|
|
503
|
+
domain: [Math.min(...corners), Math.max(...corners)]
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function textValues(type) {
|
|
507
|
+
return type.kind === 'string' || type.kind === 'number'
|
|
508
|
+
? type.values?.map(String)
|
|
509
|
+
: undefined;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* `+` with text on either side joins as JavaScript joins, rather than pairing
|
|
513
|
+
* list values, so `REF + '>' + ALT` is one string.
|
|
514
|
+
*/
|
|
515
|
+
function joined(left, right) {
|
|
516
|
+
const a = textValues(left);
|
|
517
|
+
const b = textValues(right);
|
|
518
|
+
return a && b && a.length * b.length <= MAX_VALUES
|
|
519
|
+
? { kind: 'string', values: a.flatMap((x) => b.map((y) => x + y)) }
|
|
520
|
+
: STRING;
|
|
521
|
+
}
|
|
522
|
+
const PASSES_INDEX = new Set(['map', 'filter', 'any', 'all', 'count', 'find']);
|
|
523
|
+
/** The params of a lambda that a function walks over a list's items. */
|
|
524
|
+
function lambdaParams(name, item) {
|
|
525
|
+
switch (name) {
|
|
526
|
+
case 'sort': {
|
|
527
|
+
return [item, item];
|
|
528
|
+
}
|
|
529
|
+
case 'reduce': {
|
|
530
|
+
return [UNKNOWN, item];
|
|
531
|
+
}
|
|
532
|
+
default: {
|
|
533
|
+
return PASSES_INDEX.has(name) ? [item, NUMBER] : [item];
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function negate(type) {
|
|
538
|
+
switch (type.kind) {
|
|
539
|
+
case 'list': {
|
|
540
|
+
return {
|
|
541
|
+
kind: 'list',
|
|
542
|
+
of: negate(type.of),
|
|
543
|
+
cardinality: type.cardinality
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
case 'union': {
|
|
547
|
+
return unionAll(type.of.map(negate));
|
|
548
|
+
}
|
|
549
|
+
case 'unknown': {
|
|
550
|
+
return UNKNOWN;
|
|
551
|
+
}
|
|
552
|
+
case 'number': {
|
|
553
|
+
if (type.values) {
|
|
554
|
+
return { kind: 'number', values: type.values.map((v) => -v) };
|
|
555
|
+
}
|
|
556
|
+
return type.domain
|
|
557
|
+
? { kind: 'number', domain: [-type.domain[1], -type.domain[0]] }
|
|
558
|
+
: NUMBER;
|
|
559
|
+
}
|
|
560
|
+
default: {
|
|
561
|
+
return NUMBER;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function paramName(subject, context) {
|
|
566
|
+
const node = subject;
|
|
567
|
+
const base = node.type === 'Identifier'
|
|
568
|
+
? node.value.toLowerCase().replaceAll(/\W/g, '')
|
|
569
|
+
: '';
|
|
570
|
+
const name = isName(base) ? base : 'x';
|
|
571
|
+
const text = print(context);
|
|
572
|
+
let candidate = name;
|
|
573
|
+
for (let i = 2; new RegExp(String.raw `\b${candidate}\b`).test(text); i++) {
|
|
574
|
+
candidate = `${name}${i}`;
|
|
575
|
+
}
|
|
576
|
+
return candidate;
|
|
577
|
+
}
|
|
578
|
+
function literalValue(node) {
|
|
579
|
+
return node.type === 'Literal' ? node.value : undefined;
|
|
580
|
+
}
|
|
581
|
+
function describe(type) {
|
|
582
|
+
switch (type.kind) {
|
|
583
|
+
case 'list': {
|
|
584
|
+
return `a list of ${describe(type.of)}`;
|
|
585
|
+
}
|
|
586
|
+
case 'record': {
|
|
587
|
+
return 'a record';
|
|
588
|
+
}
|
|
589
|
+
case 'number': {
|
|
590
|
+
return type.values?.length === 1 ? String(type.values[0]) : 'a number';
|
|
591
|
+
}
|
|
592
|
+
case 'string': {
|
|
593
|
+
return type.values?.length === 1 ? quote(type.values[0]) : 'text';
|
|
594
|
+
}
|
|
595
|
+
case 'boolean': {
|
|
596
|
+
return type.field?.type === 'flag' ? 'a flag' : 'a boolean';
|
|
597
|
+
}
|
|
598
|
+
default: {
|
|
599
|
+
return type.kind;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function literalOf(type) {
|
|
604
|
+
if (type.kind === 'string' && type.values?.length === 1) {
|
|
605
|
+
return type.values[0];
|
|
606
|
+
}
|
|
607
|
+
return type.kind === 'number' && type.values?.length === 1
|
|
608
|
+
? type.values[0]
|
|
609
|
+
: undefined;
|
|
610
|
+
}
|
|
611
|
+
function holding(type) {
|
|
612
|
+
switch (type.cardinality) {
|
|
613
|
+
case 'perAlt': {
|
|
614
|
+
return 'holds one value per ALT allele';
|
|
615
|
+
}
|
|
616
|
+
case 'perAllele': {
|
|
617
|
+
return 'holds one value per allele, REF first';
|
|
618
|
+
}
|
|
619
|
+
case 'perGenotype': {
|
|
620
|
+
return 'holds one value per possible genotype';
|
|
621
|
+
}
|
|
622
|
+
case 'many': {
|
|
623
|
+
return 'holds any number of values';
|
|
624
|
+
}
|
|
625
|
+
default: {
|
|
626
|
+
return `holds ${type.cardinality} values`;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function firstAbsentIndex(cardinality) {
|
|
631
|
+
switch (cardinality) {
|
|
632
|
+
case 'perAlt': {
|
|
633
|
+
return 1;
|
|
634
|
+
}
|
|
635
|
+
case 'perAllele': {
|
|
636
|
+
return 2;
|
|
637
|
+
}
|
|
638
|
+
case 'perGenotype': {
|
|
639
|
+
return 3;
|
|
640
|
+
}
|
|
641
|
+
case 'many': {
|
|
642
|
+
return Infinity;
|
|
643
|
+
}
|
|
644
|
+
default: {
|
|
645
|
+
return cardinality;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Checks an expression against the fields a host's data declares and the
|
|
651
|
+
* functions it registers, without evaluating it, and infers what it answers.
|
|
652
|
+
*/
|
|
653
|
+
export function check(ast, { schema, functions, accessors = {}, row, env } = {}) {
|
|
654
|
+
const diagnostics = [];
|
|
655
|
+
const root = schema ? recordOf(schema) : UNKNOWN;
|
|
656
|
+
const scope = new Map();
|
|
657
|
+
function report(code, severity, node, message, suggestions) {
|
|
658
|
+
diagnostics.push({
|
|
659
|
+
code,
|
|
660
|
+
severity,
|
|
661
|
+
message,
|
|
662
|
+
node,
|
|
663
|
+
...(suggestions?.length ? { suggestions } : {})
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
function text(node, name) {
|
|
667
|
+
return node ? print(node) + key(name) : name;
|
|
668
|
+
}
|
|
669
|
+
function field(subject, name, node, subjectNode, spell = (candidate) => text(subjectNode, candidate)) {
|
|
670
|
+
const where = subjectNode ? print(subjectNode) : 'the row';
|
|
671
|
+
switch (subject.kind) {
|
|
672
|
+
case 'record': {
|
|
673
|
+
const found = subject.fields.get(name) ?? subject.each;
|
|
674
|
+
if (found) {
|
|
675
|
+
return found;
|
|
676
|
+
}
|
|
677
|
+
if (!subject.open) {
|
|
678
|
+
const near = nearest(name, subject.fields.keys());
|
|
679
|
+
report('unknown-field', 'warning', node, `${where} has no field ${name}` +
|
|
680
|
+
(near.length ? `; did you mean ${near.join(', ')}?` : ''), near.map(spell));
|
|
681
|
+
}
|
|
682
|
+
return UNKNOWN;
|
|
683
|
+
}
|
|
684
|
+
case 'list': {
|
|
685
|
+
if (name === 'length') {
|
|
686
|
+
return { kind: 'number', domain: [0, Infinity] };
|
|
687
|
+
}
|
|
688
|
+
const item = subject.of;
|
|
689
|
+
const entriesHave = item.kind === 'unknown' ||
|
|
690
|
+
(item.kind === 'record' &&
|
|
691
|
+
(item.open || !!item.each || item.fields.has(name)));
|
|
692
|
+
const suggestions = [];
|
|
693
|
+
if (entriesHave && subjectNode && node.type === 'Identifier') {
|
|
694
|
+
const list = print(subjectNode);
|
|
695
|
+
const param = paramName(subjectNode, node);
|
|
696
|
+
suggestions.push(`${list}[0]${key(name)}`);
|
|
697
|
+
if (functions?.map) {
|
|
698
|
+
suggestions.push(`map(${list}, ${param} => ${param}${key(name)})`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
report('dot-through-list', 'warning', node, `${where} is a list, so ${key(name)} is undefined` +
|
|
702
|
+
(entriesHave
|
|
703
|
+
? `; [0]${key(name)} reads the first entry's` +
|
|
704
|
+
(functions?.any ? ', and any() tests each' : '')
|
|
705
|
+
: ''), suggestions);
|
|
706
|
+
return UNDEFINED;
|
|
707
|
+
}
|
|
708
|
+
case 'string': {
|
|
709
|
+
if (name === 'length') {
|
|
710
|
+
return { kind: 'number', domain: [0, Infinity] };
|
|
711
|
+
}
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
case 'number':
|
|
715
|
+
case 'boolean': {
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
default: {
|
|
719
|
+
return UNKNOWN;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
report('unknown-field', 'warning', node, `${where} is ${describe(subject)}, which has no field ${name}`);
|
|
723
|
+
return UNKNOWN;
|
|
724
|
+
}
|
|
725
|
+
function index(subject, at, node, subjectNode) {
|
|
726
|
+
const where = print(subjectNode);
|
|
727
|
+
switch (subject.kind) {
|
|
728
|
+
case 'list': {
|
|
729
|
+
const absent = firstAbsentIndex(subject.cardinality);
|
|
730
|
+
if (at >= absent) {
|
|
731
|
+
const fixed = typeof subject.cardinality === 'number';
|
|
732
|
+
report('index-range', 'warning', node, fixed
|
|
733
|
+
? `${where} ${holding(subject)}, so [${at}] is always undefined`
|
|
734
|
+
: `${where} ${holding(subject)}, so [${at}] is undefined on every biallelic record`, subject.cardinality === 'perAlt' ? [`${where}[0]`] : []);
|
|
735
|
+
}
|
|
736
|
+
return subject.of;
|
|
737
|
+
}
|
|
738
|
+
case 'record': {
|
|
739
|
+
return field(subject, String(at), node, subjectNode);
|
|
740
|
+
}
|
|
741
|
+
case 'string': {
|
|
742
|
+
report('index-scalar', 'warning', node, `${where} holds one string, so [${at}] reads a single character`, [where]);
|
|
743
|
+
return STRING;
|
|
744
|
+
}
|
|
745
|
+
case 'number':
|
|
746
|
+
case 'boolean': {
|
|
747
|
+
report('index-scalar', 'warning', node, `${where} holds one value, so [${at}] reads nothing`, [where]);
|
|
748
|
+
return UNDEFINED;
|
|
749
|
+
}
|
|
750
|
+
default: {
|
|
751
|
+
return UNKNOWN;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function chain(node) {
|
|
756
|
+
const names = [node.value];
|
|
757
|
+
let base = node;
|
|
758
|
+
while (base.type === 'Identifier' && base.from) {
|
|
759
|
+
base = base.from;
|
|
760
|
+
if (base.type === 'Identifier') {
|
|
761
|
+
names.unshift(base.value);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return { base, names };
|
|
765
|
+
}
|
|
766
|
+
function dottedKey(node) {
|
|
767
|
+
if (!node.from) {
|
|
768
|
+
return undefined;
|
|
769
|
+
}
|
|
770
|
+
const { base, names } = chain(node);
|
|
771
|
+
if (base.from) {
|
|
772
|
+
return undefined;
|
|
773
|
+
}
|
|
774
|
+
let type = variable(base, false);
|
|
775
|
+
let subjectNode = base;
|
|
776
|
+
for (let i = 1; i < names.length; i++) {
|
|
777
|
+
const record = type.kind === 'record' ? type : undefined;
|
|
778
|
+
if (!record) {
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
781
|
+
for (let j = names.length; j > i + 1; j--) {
|
|
782
|
+
const whole = names.slice(i, j).join('.');
|
|
783
|
+
const step = record.fields.get(names[i]);
|
|
784
|
+
const stepReaches = step?.kind === 'record' &&
|
|
785
|
+
(step.fields.has(names[i + 1]) || !!step.each);
|
|
786
|
+
if (record.fields.has(whole) && !stepReaches) {
|
|
787
|
+
const fixed = print(subjectNode) + key(whole);
|
|
788
|
+
report('dotted-key', 'warning', node, `${whole} is one field of ${print(subjectNode)}; jexl reads ${names.slice(i, j).join(' then ')}`, [[fixed, ...names.slice(j).map((name) => key(name))].join('')]);
|
|
789
|
+
let resolved = record.fields.get(whole);
|
|
790
|
+
for (const name of names.slice(j)) {
|
|
791
|
+
resolved = field(resolved, name, node, undefined);
|
|
792
|
+
}
|
|
793
|
+
return resolved;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const next = record.fields.get(names[i]);
|
|
797
|
+
if (!next) {
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
type = next;
|
|
801
|
+
subjectNode = {
|
|
802
|
+
type: 'Identifier',
|
|
803
|
+
value: names[i],
|
|
804
|
+
from: subjectNode
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return undefined;
|
|
808
|
+
}
|
|
809
|
+
function variable(node, diagnose = true) {
|
|
810
|
+
const name = node.value;
|
|
811
|
+
const local = scope.get(name);
|
|
812
|
+
if (local) {
|
|
813
|
+
return local;
|
|
814
|
+
}
|
|
815
|
+
if (name === row) {
|
|
816
|
+
return root;
|
|
817
|
+
}
|
|
818
|
+
if (env && Object.hasOwn(env, name)) {
|
|
819
|
+
return env[name];
|
|
820
|
+
}
|
|
821
|
+
if (name === 'undefined') {
|
|
822
|
+
return UNDEFINED;
|
|
823
|
+
}
|
|
824
|
+
if (row === undefined && root.kind === 'record') {
|
|
825
|
+
return diagnose
|
|
826
|
+
? field(root, name, node, undefined)
|
|
827
|
+
: (root.fields.get(name) ?? UNKNOWN);
|
|
828
|
+
}
|
|
829
|
+
if (env && diagnose) {
|
|
830
|
+
const known = [
|
|
831
|
+
...(row ? [row] : []),
|
|
832
|
+
...Object.keys(env),
|
|
833
|
+
...scope.keys()
|
|
834
|
+
];
|
|
835
|
+
const near = nearest(name, known);
|
|
836
|
+
report('unknown-variable', 'error', node, `${name} is not defined here` +
|
|
837
|
+
(near.length ? `; did you mean ${near.join(', ')}?` : ''), near);
|
|
838
|
+
}
|
|
839
|
+
return UNKNOWN;
|
|
840
|
+
}
|
|
841
|
+
function hyphenated(node) {
|
|
842
|
+
if (node.type !== 'BinaryExpression' ||
|
|
843
|
+
node.operator !== '-' ||
|
|
844
|
+
node.left.type !== 'Identifier' ||
|
|
845
|
+
node.right?.type !== 'Identifier' ||
|
|
846
|
+
node.right.from) {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
const left = node.left;
|
|
850
|
+
const reported = diagnostics.length;
|
|
851
|
+
const subject = left.from ? infer(left.from) : root;
|
|
852
|
+
diagnostics.length = reported;
|
|
853
|
+
if (subject.kind !== 'record' || subject.fields.has(left.value)) {
|
|
854
|
+
return undefined;
|
|
855
|
+
}
|
|
856
|
+
const whole = `${left.value}-${node.right.value}`;
|
|
857
|
+
const found = subject.fields.get(whole);
|
|
858
|
+
if (!found) {
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
report('unknown-field', 'warning', node, `${print(node)} subtracts; ${whole} is one field`, [text(left.from, whole)]);
|
|
862
|
+
return found;
|
|
863
|
+
}
|
|
864
|
+
function memberFor(element, value, valueNode, listNode, node) {
|
|
865
|
+
const literal = literalOf(value);
|
|
866
|
+
if (literal === undefined) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (element.kind === 'record' && typeof literal === 'string') {
|
|
870
|
+
const holder = [...element.fields].find(([, type]) => {
|
|
871
|
+
const scalar = type.kind === 'list' ? type.of : type;
|
|
872
|
+
return scalar.kind === 'string' && scalar.values?.includes(literal);
|
|
873
|
+
});
|
|
874
|
+
report('never-equal', 'warning', node, `${print(listNode)} holds records, so no entry equals ${quote(literal)}` +
|
|
875
|
+
(holder ? `; it is a value of their ${holder[0]} field` : ''), holder && functions?.any
|
|
876
|
+
? [
|
|
877
|
+
`any(${print(listNode)}, ${paramName(listNode, node)} => ${print(valueNode)} in ${paramName(listNode, node)}${key(holder[0])})`
|
|
878
|
+
]
|
|
879
|
+
: []);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
compareLiteral(element, literal, node);
|
|
883
|
+
}
|
|
884
|
+
function compareLiteral(type, literal, node) {
|
|
885
|
+
if (type.kind === 'number' &&
|
|
886
|
+
typeof literal === 'string' &&
|
|
887
|
+
Number.isNaN(Number(literal))) {
|
|
888
|
+
report('never-equal', 'warning', node, `${quote(literal)} is not a number, so it never equals ${describe(type)}`);
|
|
889
|
+
}
|
|
890
|
+
else if (type.kind === 'boolean' && typeof literal === 'string') {
|
|
891
|
+
report('never-equal', 'warning', node, `${describe(type)} never equals the text ${quote(literal)}`, literal === 'true' || literal === 'false' ? [literal] : []);
|
|
892
|
+
}
|
|
893
|
+
else if (type.kind === 'string' &&
|
|
894
|
+
type.field?.categories &&
|
|
895
|
+
typeof literal === 'string' &&
|
|
896
|
+
!type.field.categories.includes(literal)) {
|
|
897
|
+
const near = nearest(literal, type.field.categories);
|
|
898
|
+
report('unknown-category', 'warning', node, `${quote(literal)} is not one of the values the header declares for ${type.field.path.join('.')}` +
|
|
899
|
+
(near.length ? `; did you mean ${near.map(quote).join(', ')}?` : ''), near.map(quote));
|
|
900
|
+
}
|
|
901
|
+
else if (type.kind === 'record') {
|
|
902
|
+
report('never-equal', 'warning', node, `a record never equals ${quote(String(literal))}`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function binary(node) {
|
|
906
|
+
const { operator } = node;
|
|
907
|
+
const rightNode = node.right;
|
|
908
|
+
if (operator === '&&' || operator === '||' || operator === '??') {
|
|
909
|
+
const left = infer(node.left);
|
|
910
|
+
const right = infer(rightNode);
|
|
911
|
+
if (operator === '&&') {
|
|
912
|
+
return union(falsyPart(left), right);
|
|
913
|
+
}
|
|
914
|
+
return union(operator === '||' ? truthyPart(left) : left, right);
|
|
915
|
+
}
|
|
916
|
+
const hyphen = hyphenated(node);
|
|
917
|
+
if (hyphen) {
|
|
918
|
+
return hyphen;
|
|
919
|
+
}
|
|
920
|
+
const left = infer(node.left);
|
|
921
|
+
const right = infer(rightNode);
|
|
922
|
+
if (operator === '~' || operator === '!~') {
|
|
923
|
+
const pattern = literalOf(right);
|
|
924
|
+
if (typeof pattern === 'string' && !isPattern(pattern)) {
|
|
925
|
+
report('invalid-pattern', 'error', node, `${quote(pattern)} is not a valid regular expression`);
|
|
926
|
+
}
|
|
927
|
+
return BOOLEAN;
|
|
928
|
+
}
|
|
929
|
+
const leftItem = left.kind === 'list' ? left.of : left;
|
|
930
|
+
const rightItem = right.kind === 'list' ? right.of : right;
|
|
931
|
+
if (operator === '+' &&
|
|
932
|
+
(leftItem.kind === 'string' || rightItem.kind === 'string')) {
|
|
933
|
+
return joined(left, right);
|
|
934
|
+
}
|
|
935
|
+
const lists = [left, right].filter((type) => type.kind === 'list');
|
|
936
|
+
const [first, second] = lists;
|
|
937
|
+
if (first &&
|
|
938
|
+
second &&
|
|
939
|
+
(ARITHMETIC.has(operator) || operator === '+') &&
|
|
940
|
+
!pairs(first.cardinality, second.cardinality)) {
|
|
941
|
+
report('list-lengths', 'warning', node, `${print(node.left)} ${holding(first)} and ${print(rightNode)} ${holding(second)}, so ${operator} pairs them only where both hold one value`);
|
|
942
|
+
}
|
|
943
|
+
const eachOf = (item) => first ? { kind: 'list', of: item, cardinality: first.cardinality } : item;
|
|
944
|
+
if (operator === '+') {
|
|
945
|
+
return isNumberish(leftItem) && isNumberish(rightItem)
|
|
946
|
+
? eachOf(arithmetic('+', leftItem, rightItem))
|
|
947
|
+
: UNKNOWN;
|
|
948
|
+
}
|
|
949
|
+
if (ARITHMETIC.has(operator) || ORDER.has(operator)) {
|
|
950
|
+
for (const [type, typeNode, other] of [
|
|
951
|
+
[leftItem, node.left, rightItem],
|
|
952
|
+
[rightItem, rightNode, leftItem]
|
|
953
|
+
]) {
|
|
954
|
+
const numericOther = ARITHMETIC.has(operator) || isNumberish(other);
|
|
955
|
+
if (type.kind === 'string' && type.field && numericOther) {
|
|
956
|
+
report('string-numeric', 'warning', node, `${print(typeNode)} is text, so ${operator} converts it to a number, ${ORDER.has(operator) ? 'answering false' : 'giving NaN'} wherever it is not one`, functions?.parseFloat
|
|
957
|
+
? [
|
|
958
|
+
print(node).replace(print(typeNode), `parseFloat(${print(typeNode)})`)
|
|
959
|
+
]
|
|
960
|
+
: []);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return ORDER.has(operator)
|
|
964
|
+
? BOOLEAN
|
|
965
|
+
: eachOf(arithmetic(operator, leftItem, rightItem));
|
|
966
|
+
}
|
|
967
|
+
if (EQUALITY.has(operator)) {
|
|
968
|
+
const leftLiteral = literalOf(leftItem);
|
|
969
|
+
const rightLiteral = literalOf(rightItem);
|
|
970
|
+
if (rightLiteral !== undefined && node.right?.type === 'Literal') {
|
|
971
|
+
compareLiteral(leftItem, rightLiteral, node);
|
|
972
|
+
}
|
|
973
|
+
else if (leftLiteral !== undefined && node.left.type === 'Literal') {
|
|
974
|
+
compareLiteral(rightItem, leftLiteral, node);
|
|
975
|
+
}
|
|
976
|
+
return BOOLEAN;
|
|
977
|
+
}
|
|
978
|
+
if (operator === 'in') {
|
|
979
|
+
if (right.kind === 'list') {
|
|
980
|
+
memberFor(right.of, leftItem, node.left, rightNode, node);
|
|
981
|
+
}
|
|
982
|
+
else if (right.kind === 'number' || right.kind === 'boolean') {
|
|
983
|
+
report('never-equal', 'warning', node, `in tests a list, text, a Set, a Map or an object's keys, and ${print(rightNode)} is ${describe(right)}`);
|
|
984
|
+
}
|
|
985
|
+
return BOOLEAN;
|
|
986
|
+
}
|
|
987
|
+
return UNKNOWN;
|
|
988
|
+
}
|
|
989
|
+
function argumentProblem(param, type) {
|
|
990
|
+
switch (param) {
|
|
991
|
+
case 'any':
|
|
992
|
+
case 'boolean':
|
|
993
|
+
case 'member':
|
|
994
|
+
case 'lambda': {
|
|
995
|
+
return undefined;
|
|
996
|
+
}
|
|
997
|
+
case 'number': {
|
|
998
|
+
if (type.kind === 'string' && type.field) {
|
|
999
|
+
return 'is text';
|
|
1000
|
+
}
|
|
1001
|
+
return isMulti(type) || type.kind === 'record'
|
|
1002
|
+
? `is ${describe(type)}`
|
|
1003
|
+
: undefined;
|
|
1004
|
+
}
|
|
1005
|
+
case 'string': {
|
|
1006
|
+
return isMulti(type) || type.kind === 'record'
|
|
1007
|
+
? `is ${describe(type)}`
|
|
1008
|
+
: undefined;
|
|
1009
|
+
}
|
|
1010
|
+
case 'list': {
|
|
1011
|
+
return ['number', 'string', 'boolean', 'record'].includes(type.kind)
|
|
1012
|
+
? `is ${describe(type)}`
|
|
1013
|
+
: undefined;
|
|
1014
|
+
}
|
|
1015
|
+
case 'record': {
|
|
1016
|
+
return ['number', 'string', 'boolean', 'list'].includes(type.kind)
|
|
1017
|
+
? `is ${describe(type)}`
|
|
1018
|
+
: undefined;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
function lambda(node, types = []) {
|
|
1023
|
+
const saved = node.params.map((name) => [name, scope.get(name)]);
|
|
1024
|
+
node.params.forEach((name, i) => {
|
|
1025
|
+
scope.set(name, types[i] ?? UNKNOWN);
|
|
1026
|
+
});
|
|
1027
|
+
const returns = infer(node.body);
|
|
1028
|
+
for (const [name, previous] of saved) {
|
|
1029
|
+
if (previous) {
|
|
1030
|
+
scope.set(name, previous);
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
scope.delete(name);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return { kind: 'lambda', returns };
|
|
1037
|
+
}
|
|
1038
|
+
function call(node) {
|
|
1039
|
+
const { name, args } = node;
|
|
1040
|
+
const prefix = Object.hasOwn(accessors, name) ? accessors[name] : undefined;
|
|
1041
|
+
if (prefix && args[0]) {
|
|
1042
|
+
const [subjectArg, ...keyArgs] = args;
|
|
1043
|
+
let type = infer(subjectArg);
|
|
1044
|
+
let subjectNode = subjectArg;
|
|
1045
|
+
const step = (name, at, spell) => {
|
|
1046
|
+
type = field(type, name, at, subjectNode, spell);
|
|
1047
|
+
subjectNode = {
|
|
1048
|
+
type: 'Identifier',
|
|
1049
|
+
value: name,
|
|
1050
|
+
from: subjectNode
|
|
1051
|
+
};
|
|
1052
|
+
};
|
|
1053
|
+
for (const name of prefix) {
|
|
1054
|
+
step(String(name), node);
|
|
1055
|
+
}
|
|
1056
|
+
for (const arg of keyArgs) {
|
|
1057
|
+
const name = literalValue(arg);
|
|
1058
|
+
if (typeof name !== 'string' && typeof name !== 'number') {
|
|
1059
|
+
keyArgs.forEach(infer);
|
|
1060
|
+
return UNKNOWN;
|
|
1061
|
+
}
|
|
1062
|
+
step(String(name), arg, quote);
|
|
1063
|
+
}
|
|
1064
|
+
return type;
|
|
1065
|
+
}
|
|
1066
|
+
const signature = functions && Object.hasOwn(functions, name) ? functions[name] : undefined;
|
|
1067
|
+
if (!signature) {
|
|
1068
|
+
args.forEach(infer);
|
|
1069
|
+
if (functions) {
|
|
1070
|
+
const near = nearest(name, Object.keys(functions));
|
|
1071
|
+
report('unknown-function', 'error', node, `no function ${name}` +
|
|
1072
|
+
(near.length ? `; did you mean ${near.join(', ')}?` : ''), near.map((candidate) => print({ ...node, name: candidate })));
|
|
1073
|
+
}
|
|
1074
|
+
return UNKNOWN;
|
|
1075
|
+
}
|
|
1076
|
+
const { params, optional = 0, variadic = false } = signature;
|
|
1077
|
+
const least = params.length - optional - (variadic ? 1 : 0);
|
|
1078
|
+
const most = variadic ? Infinity : params.length;
|
|
1079
|
+
if (args.length < least || args.length > most) {
|
|
1080
|
+
report('arity', 'error', node, `${name} takes ${least === most ? least : most === Infinity ? `at least ${least}` : `${least} to ${most}`} argument${most === 1 ? '' : 's'}, not ${args.length}`);
|
|
1081
|
+
}
|
|
1082
|
+
const types = [];
|
|
1083
|
+
let lastCollection;
|
|
1084
|
+
let lastCollectionNode;
|
|
1085
|
+
for (const [i, arg] of args.entries()) {
|
|
1086
|
+
const declared = params[Math.min(i, params.length - 1)];
|
|
1087
|
+
if (declared === undefined || (!variadic && i >= params.length)) {
|
|
1088
|
+
types.push(infer(arg));
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
const accepted = typeof declared === 'string' ? [declared] : declared;
|
|
1092
|
+
if (arg.type === 'Lambda') {
|
|
1093
|
+
const item = lastCollection?.kind === 'list' ? lastCollection.of : UNKNOWN;
|
|
1094
|
+
types.push(lambda(arg, lambdaParams(name, item)));
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
const type = infer(arg);
|
|
1098
|
+
types.push(type);
|
|
1099
|
+
if (accepted.includes('lambda')) {
|
|
1100
|
+
report('argument-type', 'error', arg, `${name} expects a function such as x => …, not ${print(arg)}`);
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
if (accepted.includes('member') && lastCollection && lastCollectionNode) {
|
|
1104
|
+
if (lastCollection.kind === 'list') {
|
|
1105
|
+
memberFor(lastCollection.of, type, arg, lastCollectionNode, node);
|
|
1106
|
+
}
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
const problems = accepted.map((param) => argumentProblem(param, type));
|
|
1110
|
+
if (problems.every(Boolean)) {
|
|
1111
|
+
report(isMulti(type)
|
|
1112
|
+
? 'list-operand'
|
|
1113
|
+
: type.kind === 'string'
|
|
1114
|
+
? 'string-numeric'
|
|
1115
|
+
: 'argument-type', 'warning', arg, `${name} expects ${accepted.join(' or ')} as argument ${i + 1}, but ${print(arg)} ${problems[0]}`);
|
|
1116
|
+
}
|
|
1117
|
+
if (accepted.includes('list')) {
|
|
1118
|
+
lastCollection = type;
|
|
1119
|
+
lastCollectionNode = arg;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const { returns } = signature;
|
|
1123
|
+
if (typeof returns === 'function') {
|
|
1124
|
+
return returns(types);
|
|
1125
|
+
}
|
|
1126
|
+
if (typeof returns === 'string') {
|
|
1127
|
+
return { kind: returns };
|
|
1128
|
+
}
|
|
1129
|
+
return returns ?? UNKNOWN;
|
|
1130
|
+
}
|
|
1131
|
+
function infer(ast) {
|
|
1132
|
+
const node = ast;
|
|
1133
|
+
switch (node.type) {
|
|
1134
|
+
case 'Literal': {
|
|
1135
|
+
const { value } = node;
|
|
1136
|
+
return typeof value === 'number'
|
|
1137
|
+
? { kind: 'number', values: [value] }
|
|
1138
|
+
: typeof value === 'string'
|
|
1139
|
+
? { kind: 'string', values: [value] }
|
|
1140
|
+
: value === null
|
|
1141
|
+
? UNDEFINED
|
|
1142
|
+
: BOOLEAN;
|
|
1143
|
+
}
|
|
1144
|
+
case 'Identifier': {
|
|
1145
|
+
if (!node.from) {
|
|
1146
|
+
return variable(node);
|
|
1147
|
+
}
|
|
1148
|
+
const dotted = dottedKey(node);
|
|
1149
|
+
if (dotted) {
|
|
1150
|
+
return dotted;
|
|
1151
|
+
}
|
|
1152
|
+
return field(infer(node.from), node.value, node, node.from);
|
|
1153
|
+
}
|
|
1154
|
+
case 'FilterExpression': {
|
|
1155
|
+
const subject = infer(node.subject);
|
|
1156
|
+
const at = literalValue(node.expr);
|
|
1157
|
+
if (typeof at === 'number') {
|
|
1158
|
+
return index(subject, at, node, node.subject);
|
|
1159
|
+
}
|
|
1160
|
+
if (typeof at === 'string') {
|
|
1161
|
+
if (subject.kind === 'list' && at !== 'length') {
|
|
1162
|
+
report('unknown-field', 'warning', node, `${print(node.subject)} is ${describe(subject)}, so [${quote(at)}] reads nothing`);
|
|
1163
|
+
return UNDEFINED;
|
|
1164
|
+
}
|
|
1165
|
+
return field(subject, at, node, node.subject);
|
|
1166
|
+
}
|
|
1167
|
+
const by = infer(node.expr);
|
|
1168
|
+
if (isMulti(by)) {
|
|
1169
|
+
report('list-operand', 'warning', node, `${print(node.expr)} ${holding(by)}, so indexing by it finds an entry only when there is exactly one`);
|
|
1170
|
+
}
|
|
1171
|
+
if (subject.kind === 'record') {
|
|
1172
|
+
return unionAll([
|
|
1173
|
+
...subject.fields.values(),
|
|
1174
|
+
subject.each ?? UNDEFINED
|
|
1175
|
+
]);
|
|
1176
|
+
}
|
|
1177
|
+
return subject.kind === 'list' ? subject.of : UNKNOWN;
|
|
1178
|
+
}
|
|
1179
|
+
case 'BinaryExpression': {
|
|
1180
|
+
return binary(node);
|
|
1181
|
+
}
|
|
1182
|
+
case 'UnaryExpression': {
|
|
1183
|
+
const right = infer(node.right);
|
|
1184
|
+
if (node.operator === '!') {
|
|
1185
|
+
return BOOLEAN;
|
|
1186
|
+
}
|
|
1187
|
+
if (node.operator === '-') {
|
|
1188
|
+
return negate(right);
|
|
1189
|
+
}
|
|
1190
|
+
return UNKNOWN;
|
|
1191
|
+
}
|
|
1192
|
+
case 'ConditionalExpression': {
|
|
1193
|
+
const test = infer(node.test);
|
|
1194
|
+
const consequent = node.consequent
|
|
1195
|
+
? infer(node.consequent)
|
|
1196
|
+
: truthyPart(test);
|
|
1197
|
+
const alternate = node.alternate ? infer(node.alternate) : UNDEFINED;
|
|
1198
|
+
return union(consequent, alternate);
|
|
1199
|
+
}
|
|
1200
|
+
case 'FunctionCall': {
|
|
1201
|
+
return call(node);
|
|
1202
|
+
}
|
|
1203
|
+
case 'ArrayLiteral': {
|
|
1204
|
+
const items = node.value.map(infer);
|
|
1205
|
+
return {
|
|
1206
|
+
kind: 'list',
|
|
1207
|
+
of: unionAll(items),
|
|
1208
|
+
cardinality: items.length
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
case 'ObjectLiteral': {
|
|
1212
|
+
return {
|
|
1213
|
+
kind: 'record',
|
|
1214
|
+
fields: new Map(Object.entries(node.value).map(([name, value]) => [
|
|
1215
|
+
name,
|
|
1216
|
+
infer(value)
|
|
1217
|
+
])),
|
|
1218
|
+
open: false
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
case 'TemplateLiteral': {
|
|
1222
|
+
let values = [''];
|
|
1223
|
+
for (const part of node.parts) {
|
|
1224
|
+
if (part.type === 'static') {
|
|
1225
|
+
values = values?.map((value) => value + part.value);
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
const options = textValues(infer(part.value));
|
|
1229
|
+
values =
|
|
1230
|
+
values && options && values.length * options.length <= MAX_VALUES
|
|
1231
|
+
? values.flatMap((value) => options.map((option) => value + option))
|
|
1232
|
+
: undefined;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return values ? { kind: 'string', values } : STRING;
|
|
1236
|
+
}
|
|
1237
|
+
case 'SequenceExpression': {
|
|
1238
|
+
let last = UNDEFINED;
|
|
1239
|
+
for (const expr of node.expressions) {
|
|
1240
|
+
last = infer(expr);
|
|
1241
|
+
}
|
|
1242
|
+
return last;
|
|
1243
|
+
}
|
|
1244
|
+
case 'AssignmentExpression': {
|
|
1245
|
+
const value = infer(node.right);
|
|
1246
|
+
scope.set(node.left.value, value);
|
|
1247
|
+
return value;
|
|
1248
|
+
}
|
|
1249
|
+
case 'Lambda': {
|
|
1250
|
+
return lambda(node);
|
|
1251
|
+
}
|
|
1252
|
+
default: {
|
|
1253
|
+
throw new Error(`Corrupt AST: unknown node type '${ast.type}'`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const type = ast ? infer(ast) : UNDEFINED;
|
|
1258
|
+
return { diagnostics, type };
|
|
1259
|
+
}
|
|
1260
|
+
//# sourceMappingURL=check.js.map
|