@texuli/core 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/dist/data-source/collection.d.ts +67 -0
- package/dist/data-source/collection.js +247 -0
- package/dist/data-source/errors.d.ts +58 -0
- package/dist/data-source/errors.js +138 -0
- package/dist/data-source/index.d.ts +17 -0
- package/dist/data-source/index.js +17 -0
- package/dist/data-source/loader.d.ts +120 -0
- package/dist/data-source/loader.js +571 -0
- package/dist/data-source/query.d.ts +78 -0
- package/dist/data-source/query.js +339 -0
- package/dist/data-source/registry.d.ts +83 -0
- package/dist/data-source/registry.js +63 -0
- package/dist/data-source/schema.d.ts +122 -0
- package/dist/data-source/schema.js +197 -0
- package/dist/expression/analyze.d.ts +17 -0
- package/dist/expression/analyze.js +151 -0
- package/dist/expression/evaluate.d.ts +55 -0
- package/dist/expression/evaluate.js +365 -0
- package/dist/expression/functions.d.ts +7 -0
- package/dist/expression/functions.js +374 -0
- package/dist/expression/index.d.ts +7 -0
- package/dist/expression/index.js +12 -0
- package/dist/expression/parse.d.ts +19 -0
- package/dist/expression/parse.js +284 -0
- package/dist/expression/text.d.ts +15 -0
- package/dist/expression/text.js +489 -0
- package/dist/expression/types.d.ts +197 -0
- package/dist/expression/types.js +35 -0
- package/dist/expression/values.d.ts +14 -0
- package/dist/expression/values.js +68 -0
- package/dist/i18n/catalog.d.ts +51 -0
- package/dist/i18n/catalog.js +109 -0
- package/dist/i18n/format.d.ts +41 -0
- package/dist/i18n/format.js +117 -0
- package/dist/i18n/i18n.d.ts +70 -0
- package/dist/i18n/i18n.js +185 -0
- package/dist/i18n/index.d.ts +8 -0
- package/dist/i18n/index.js +8 -0
- package/dist/i18n/pseudo.d.ts +4 -0
- package/dist/i18n/pseudo.js +41 -0
- package/package.json +4 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { CORE_FUNCTIONS } from "./functions.js";
|
|
2
|
+
import { EXPRESSION_LIMITS, EXPRESSION_NAME, } from "./types.js";
|
|
3
|
+
const BINARY = { left: 'node', right: 'node' };
|
|
4
|
+
const LIST = { args: 'nodes' };
|
|
5
|
+
/** The members each op takes (all required except `digits`). */
|
|
6
|
+
const MEMBERS = Object.freeze({
|
|
7
|
+
num: { value: 'number' },
|
|
8
|
+
str: { value: 'string' },
|
|
9
|
+
bool: { value: 'boolean' },
|
|
10
|
+
null: {},
|
|
11
|
+
var: { name: 'name' },
|
|
12
|
+
field: { name: 'name' },
|
|
13
|
+
ref: { ref: 'ref' },
|
|
14
|
+
range: { from: 'ref', to: 'ref' },
|
|
15
|
+
add: BINARY,
|
|
16
|
+
sub: BINARY,
|
|
17
|
+
mul: BINARY,
|
|
18
|
+
div: BINARY,
|
|
19
|
+
mod: BINARY,
|
|
20
|
+
pow: { base: 'node', exponent: 'node' },
|
|
21
|
+
neg: { value: 'node' },
|
|
22
|
+
lt: BINARY,
|
|
23
|
+
lte: BINARY,
|
|
24
|
+
gt: BINARY,
|
|
25
|
+
gte: BINARY,
|
|
26
|
+
eq: BINARY,
|
|
27
|
+
neq: BINARY,
|
|
28
|
+
and: LIST,
|
|
29
|
+
or: LIST,
|
|
30
|
+
not: { value: 'node' },
|
|
31
|
+
if: { test: 'node', ifTrue: 'node', ifFalse: 'node' },
|
|
32
|
+
min: LIST,
|
|
33
|
+
max: LIST,
|
|
34
|
+
concat: LIST,
|
|
35
|
+
round: { value: 'node', digits: 'digits' },
|
|
36
|
+
clamp: { value: 'node', min: 'node', max: 'node' },
|
|
37
|
+
call: { fn: 'name', args: 'nodes' },
|
|
38
|
+
});
|
|
39
|
+
const OPTIONAL = new Set(['digits']);
|
|
40
|
+
/** Every op of the AST, in a stable order. */
|
|
41
|
+
export const EXPRESSION_OPS = Object.freeze(Object.keys(MEMBERS));
|
|
42
|
+
const MAX_DIAGNOSTICS = 20;
|
|
43
|
+
const MAX_REF_LENGTH = 256;
|
|
44
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
45
|
+
/** Turns a validated AST into an `Expression` with its dependencies. */
|
|
46
|
+
export function summarize(ast, nodes, depth) {
|
|
47
|
+
const fields = new Set();
|
|
48
|
+
const functions = new Set();
|
|
49
|
+
const refs = new Set();
|
|
50
|
+
const ranges = [];
|
|
51
|
+
const stack = [ast];
|
|
52
|
+
while (stack.length > 0) {
|
|
53
|
+
const node = stack.pop();
|
|
54
|
+
switch (node.op) {
|
|
55
|
+
case 'var':
|
|
56
|
+
case 'field':
|
|
57
|
+
fields.add(node.name);
|
|
58
|
+
break;
|
|
59
|
+
case 'ref':
|
|
60
|
+
refs.add(node.ref);
|
|
61
|
+
break;
|
|
62
|
+
case 'range':
|
|
63
|
+
ranges.push(Object.freeze({ from: node.from, to: node.to }));
|
|
64
|
+
break;
|
|
65
|
+
case 'call':
|
|
66
|
+
functions.add(node.fn);
|
|
67
|
+
break;
|
|
68
|
+
default:
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
for (const child of children(node))
|
|
72
|
+
stack.push(child);
|
|
73
|
+
}
|
|
74
|
+
const sorted = (set) => Object.freeze([...set].sort());
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
ast,
|
|
77
|
+
nodes,
|
|
78
|
+
depth,
|
|
79
|
+
fields: sorted(fields),
|
|
80
|
+
functions: sorted(functions),
|
|
81
|
+
refs: sorted(refs),
|
|
82
|
+
ranges: Object.freeze(ranges.reverse()),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** The child nodes of a node, in member order. */
|
|
86
|
+
export function children(node) {
|
|
87
|
+
switch (node.op) {
|
|
88
|
+
case 'add':
|
|
89
|
+
case 'sub':
|
|
90
|
+
case 'mul':
|
|
91
|
+
case 'div':
|
|
92
|
+
case 'mod':
|
|
93
|
+
case 'lt':
|
|
94
|
+
case 'lte':
|
|
95
|
+
case 'gt':
|
|
96
|
+
case 'gte':
|
|
97
|
+
case 'eq':
|
|
98
|
+
case 'neq':
|
|
99
|
+
return [node.left, node.right];
|
|
100
|
+
case 'pow':
|
|
101
|
+
return [node.base, node.exponent];
|
|
102
|
+
case 'neg':
|
|
103
|
+
case 'not':
|
|
104
|
+
case 'round':
|
|
105
|
+
return [node.value];
|
|
106
|
+
case 'clamp':
|
|
107
|
+
return [node.value, node.min, node.max];
|
|
108
|
+
case 'if':
|
|
109
|
+
return [node.test, node.ifTrue, node.ifFalse];
|
|
110
|
+
case 'and':
|
|
111
|
+
case 'or':
|
|
112
|
+
case 'min':
|
|
113
|
+
case 'max':
|
|
114
|
+
case 'concat':
|
|
115
|
+
case 'call':
|
|
116
|
+
return node.args;
|
|
117
|
+
default:
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Parse an untrusted value as an expression AST (the JSON form). Never
|
|
123
|
+
* throws; on success the AST is a frozen copy holding only known members.
|
|
124
|
+
*/
|
|
125
|
+
export function parseExpression(input, options = {}) {
|
|
126
|
+
const functions = options.functions ?? CORE_FUNCTIONS;
|
|
127
|
+
const references = options.references;
|
|
128
|
+
const diagnostics = [];
|
|
129
|
+
let nodes = 0;
|
|
130
|
+
let maxDepth = 0;
|
|
131
|
+
let stopped = false;
|
|
132
|
+
const report = (code, path, message) => {
|
|
133
|
+
if (diagnostics.length < MAX_DIAGNOSTICS)
|
|
134
|
+
diagnostics.push({ code, path, message });
|
|
135
|
+
if (code === 'too-many-nodes' || diagnostics.length >= MAX_DIAGNOSTICS)
|
|
136
|
+
stopped = true;
|
|
137
|
+
};
|
|
138
|
+
const visit = (value, path, depth) => {
|
|
139
|
+
if (stopped)
|
|
140
|
+
return null;
|
|
141
|
+
if (depth > EXPRESSION_LIMITS.depth) {
|
|
142
|
+
report('too-deep', path, `Expressions nest at most ${EXPRESSION_LIMITS.depth} levels.`);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
nodes += 1;
|
|
146
|
+
if (nodes > EXPRESSION_LIMITS.nodes) {
|
|
147
|
+
report('too-many-nodes', path, `An expression has at most ${EXPRESSION_LIMITS.nodes} nodes.`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (depth > maxDepth)
|
|
151
|
+
maxDepth = depth;
|
|
152
|
+
if (!isRecord(value)) {
|
|
153
|
+
report('not-a-node', path, 'An expression node is an object with an "op".');
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const op = value.op;
|
|
157
|
+
if (typeof op !== 'string' || !Object.hasOwn(MEMBERS, op)) {
|
|
158
|
+
report('unknown-op', `${path}.op`, `Unknown op ${typeof op === 'string' ? `"${op.slice(0, 32)}"` : ''}.`.replace(' .', '.'));
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const members = MEMBERS[op];
|
|
162
|
+
for (const key of Object.keys(value)) {
|
|
163
|
+
if (key !== 'op' && !Object.hasOwn(members, key)) {
|
|
164
|
+
report('unknown-member', `${path}.${key.slice(0, 32)}`, `"${op}" takes no "${key.slice(0, 32)}".`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const out = { op };
|
|
168
|
+
for (const [member, kind] of Object.entries(members)) {
|
|
169
|
+
const at = `${path}.${member}`;
|
|
170
|
+
if (!Object.hasOwn(value, member) || value[member] === undefined) {
|
|
171
|
+
if (!OPTIONAL.has(member))
|
|
172
|
+
report('missing-member', at, `"${op}" needs "${member}".`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const raw = value[member];
|
|
176
|
+
switch (kind) {
|
|
177
|
+
case 'node': {
|
|
178
|
+
const child = visit(raw, at, depth + 1);
|
|
179
|
+
if (child)
|
|
180
|
+
out[member] = child;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case 'nodes': {
|
|
184
|
+
if (!Array.isArray(raw)) {
|
|
185
|
+
report('bad-member', at, `"${member}" is a list of expressions.`);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
const min = op === 'call' ? 0 : 1;
|
|
189
|
+
if (raw.length < min || raw.length > EXPRESSION_LIMITS.args) {
|
|
190
|
+
report('bad-member', at, `"${member}" holds ${min} to ${EXPRESSION_LIMITS.args} expressions.`);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
const list = [];
|
|
194
|
+
for (let index = 0; index < raw.length && !stopped; index += 1) {
|
|
195
|
+
const child = visit(raw[index], `${at}[${index}]`, depth + 1);
|
|
196
|
+
if (child)
|
|
197
|
+
list.push(child);
|
|
198
|
+
}
|
|
199
|
+
out[member] = Object.freeze(list);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case 'number':
|
|
203
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
|
204
|
+
report('bad-member', at, '"value" is a finite number.');
|
|
205
|
+
}
|
|
206
|
+
else
|
|
207
|
+
out[member] = raw === 0 ? 0 : raw;
|
|
208
|
+
break;
|
|
209
|
+
case 'string':
|
|
210
|
+
if (typeof raw !== 'string')
|
|
211
|
+
report('bad-member', at, '"value" is a string.');
|
|
212
|
+
else if (raw.length > EXPRESSION_LIMITS.stringLength) {
|
|
213
|
+
report('too-long', at, `Strings hold at most ${EXPRESSION_LIMITS.stringLength} characters.`);
|
|
214
|
+
}
|
|
215
|
+
else
|
|
216
|
+
out[member] = raw;
|
|
217
|
+
break;
|
|
218
|
+
case 'boolean':
|
|
219
|
+
if (typeof raw !== 'boolean')
|
|
220
|
+
report('bad-member', at, '"value" is true or false.');
|
|
221
|
+
else
|
|
222
|
+
out[member] = raw;
|
|
223
|
+
break;
|
|
224
|
+
case 'name':
|
|
225
|
+
if (typeof raw !== 'string' || !EXPRESSION_NAME.test(raw)) {
|
|
226
|
+
report('bad-name', at, 'A name is a letter or _, then up to 63 letters, digits or _.');
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
out[member] = raw;
|
|
230
|
+
break;
|
|
231
|
+
case 'digits':
|
|
232
|
+
if (typeof raw !== 'number' ||
|
|
233
|
+
!Number.isInteger(raw) ||
|
|
234
|
+
Math.abs(raw) > EXPRESSION_LIMITS.roundDigits) {
|
|
235
|
+
report('bad-member', at, `"digits" is an integer from -${EXPRESSION_LIMITS.roundDigits} to ${EXPRESSION_LIMITS.roundDigits}.`);
|
|
236
|
+
}
|
|
237
|
+
else
|
|
238
|
+
out[member] = raw === 0 ? 0 : raw;
|
|
239
|
+
break;
|
|
240
|
+
case 'ref':
|
|
241
|
+
if (!references) {
|
|
242
|
+
report('refs-disabled', at, 'References are not part of this dialect.');
|
|
243
|
+
}
|
|
244
|
+
else if (typeof raw !== 'string' ||
|
|
245
|
+
raw.length === 0 ||
|
|
246
|
+
raw.length > MAX_REF_LENGTH ||
|
|
247
|
+
!references.isRef(raw)) {
|
|
248
|
+
report('bad-ref', at, 'Not a reference this dialect accepts.');
|
|
249
|
+
}
|
|
250
|
+
else
|
|
251
|
+
out[member] = raw;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (op === 'call' && typeof out.fn === 'string') {
|
|
256
|
+
const fn = Object.hasOwn(functions, out.fn) ? functions[out.fn] : undefined;
|
|
257
|
+
if (!fn) {
|
|
258
|
+
report('unknown-function', `${path}.fn`, `"${out.fn}" is not in the function table.`);
|
|
259
|
+
}
|
|
260
|
+
else if (Array.isArray(out.args)) {
|
|
261
|
+
const count = out.args.length;
|
|
262
|
+
if (count < fn.minArgs || count > fn.maxArgs) {
|
|
263
|
+
report('arity', `${path}.args`, fn.minArgs === fn.maxArgs
|
|
264
|
+
? `"${out.fn}" takes ${fn.minArgs} argument${fn.minArgs === 1 ? '' : 's'}.`
|
|
265
|
+
: `"${out.fn}" takes ${fn.minArgs} to ${fn.maxArgs} arguments.`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return Object.freeze(out);
|
|
270
|
+
};
|
|
271
|
+
const ast = visit(input, '$', 1);
|
|
272
|
+
if (diagnostics.length > 0 || !ast) {
|
|
273
|
+
return Object.freeze({
|
|
274
|
+
ok: false,
|
|
275
|
+
expression: null,
|
|
276
|
+
diagnostics: Object.freeze(diagnostics),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return Object.freeze({
|
|
280
|
+
ok: true,
|
|
281
|
+
expression: summarize(ast, nodes, maxDepth),
|
|
282
|
+
diagnostics: Object.freeze([]),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ExpressionParseOptions } from './parse.js';
|
|
2
|
+
import { type ExpressionNode, type ExpressionParseResult } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parse expression text (`age >= 18 and country == "CO"`) into a validated
|
|
5
|
+
* expression. Names read fields (`{ op: "field" }`); `if`, `min`, `max`,
|
|
6
|
+
* `concat`, `round` and `clamp` map to their AST ops, and other calls must
|
|
7
|
+
* be in the function table. Never throws.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseExpressionText(source: string, options?: ExpressionParseOptions): ExpressionParseResult;
|
|
10
|
+
/**
|
|
11
|
+
* Print an AST as expression text that parses back to an equivalent
|
|
12
|
+
* expression (`var` prints as a plain name, which reads back as `field`;
|
|
13
|
+
* `ref` and `range` print their dialect text).
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatExpression(ast: ExpressionNode): string;
|