@texuli/core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +11 -5
  2. package/dist/data-source/collection.d.ts +67 -0
  3. package/dist/data-source/collection.js +247 -0
  4. package/dist/data-source/errors.d.ts +58 -0
  5. package/dist/data-source/errors.js +138 -0
  6. package/dist/data-source/index.d.ts +17 -0
  7. package/dist/data-source/index.js +17 -0
  8. package/dist/data-source/loader.d.ts +120 -0
  9. package/dist/data-source/loader.js +571 -0
  10. package/dist/data-source/query.d.ts +78 -0
  11. package/dist/data-source/query.js +339 -0
  12. package/dist/data-source/registry.d.ts +83 -0
  13. package/dist/data-source/registry.js +63 -0
  14. package/dist/data-source/schema.d.ts +122 -0
  15. package/dist/data-source/schema.js +197 -0
  16. package/dist/expression/analyze.d.ts +17 -0
  17. package/dist/expression/analyze.js +151 -0
  18. package/dist/expression/evaluate.d.ts +55 -0
  19. package/dist/expression/evaluate.js +365 -0
  20. package/dist/expression/functions.d.ts +7 -0
  21. package/dist/expression/functions.js +374 -0
  22. package/dist/expression/index.d.ts +7 -0
  23. package/dist/expression/index.js +12 -0
  24. package/dist/expression/parse.d.ts +19 -0
  25. package/dist/expression/parse.js +284 -0
  26. package/dist/expression/text.d.ts +15 -0
  27. package/dist/expression/text.js +489 -0
  28. package/dist/expression/types.d.ts +197 -0
  29. package/dist/expression/types.js +35 -0
  30. package/dist/expression/values.d.ts +14 -0
  31. package/dist/expression/values.js +68 -0
  32. package/dist/i18n/catalog.d.ts +51 -0
  33. package/dist/i18n/catalog.js +109 -0
  34. package/dist/i18n/format.d.ts +41 -0
  35. package/dist/i18n/format.js +117 -0
  36. package/dist/i18n/i18n.d.ts +70 -0
  37. package/dist/i18n/i18n.js +185 -0
  38. package/dist/i18n/index.d.ts +8 -0
  39. package/dist/i18n/index.js +8 -0
  40. package/dist/i18n/pseudo.d.ts +4 -0
  41. package/dist/i18n/pseudo.js +41 -0
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.js +1 -1
  44. package/dist/portal/index.d.ts +10 -1
  45. package/dist/portal/index.js +44 -32
  46. package/dist/portal/scope.d.ts +58 -0
  47. package/dist/portal/scope.js +118 -0
  48. package/package.json +4 -1
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Data-source declarations (ADR-015): the host names each source, types its
3
+ * fields and declares what agents may ask of it. A blueprint can only name a
4
+ * declared source and query declared fields with the operators the field
5
+ * allows; nothing here builds a URL, a query string or code.
6
+ */
7
+ /** Field value types. The same five types as the `@texuli/data` grid (`GridValueType`). */
8
+ export declare const SOURCE_FIELD_TYPES: readonly ["text", "enum", "number", "date", "boolean"];
9
+ export type SourceFieldType = (typeof SOURCE_FIELD_TYPES)[number];
10
+ /** What a source can do. `search` means `list` also accepts free search text. */
11
+ export declare const SOURCE_OPERATIONS: readonly ["list", "get", "create", "update", "delete", "search"];
12
+ export type SourceOperation = (typeof SOURCE_OPERATIONS)[number];
13
+ /** How `list` pages: by an opaque cursor, by row offset, or not at all (one page). */
14
+ export declare const SOURCE_PAGINATION_STYLES: readonly ["cursor", "offset", "none"];
15
+ export type SourcePaginationStyle = (typeof SOURCE_PAGINATION_STYLES)[number];
16
+ /**
17
+ * Filter operators: exactly the `@texuli/data` grid filter model's
18
+ * `FILTER_OPERATORS`, so a query's filter is the same data the grid and the
19
+ * filter builder store.
20
+ */
21
+ export declare const SOURCE_FILTER_OPERATORS: readonly ["eq", "neq", "contains", "in", "between", "before", "after", "isEmpty", "isNotEmpty"];
22
+ export type SourceFilterOperator = (typeof SOURCE_FILTER_OPERATORS)[number];
23
+ /** Operators that apply to each field type (the grid's `OPERATORS_BY_TYPE`). */
24
+ export declare const SOURCE_OPERATORS_BY_TYPE: Readonly<Record<SourceFieldType, readonly SourceFilterOperator[]>>;
25
+ /** Every bound of the data-source vocabulary. Strings count UTF-16 units. */
26
+ export declare const SOURCE_LIMITS: Readonly<{
27
+ /** Sources one registry holds. */
28
+ sources: 256;
29
+ /** Fields one source declares. */
30
+ fields: 64;
31
+ /** Options of an enum field. */
32
+ enumOptions: 100;
33
+ /** Fields one query may project. */
34
+ projection: 32;
35
+ /** Sort keys one query may carry. */
36
+ sort: 4;
37
+ /** Free search text. */
38
+ searchLength: 200;
39
+ /** Filter tree depth and node count (inside the grid model's 6 levels and 200 nodes). */
40
+ filterDepth: 6;
41
+ filterNodes: 64;
42
+ /** Values of an `in` list (the grid's limit). */
43
+ filterValues: 100;
44
+ /** A text value in a filter (the grid's limit). */
45
+ valueLength: 500;
46
+ /** The largest page any source may declare. */
47
+ pageSize: 500;
48
+ /** An opaque pagination cursor. */
49
+ cursorLength: 512;
50
+ /** Fields one create or update may change. */
51
+ changes: 64;
52
+ }>;
53
+ /** A source name: lowercase words joined by `.`, `_` or `-`, at most 64 characters. */
54
+ export declare const SOURCE_NAME_PATTERN = "^[a-z][a-z0-9]*([._-][a-z0-9]+)*$";
55
+ /** A field name: a letter, then letters, digits or `_`, at most 64 characters. */
56
+ export declare const SOURCE_FIELD_PATTERN = "^[A-Za-z][A-Za-z0-9_]*$";
57
+ /** Whether `value` is a well-formed source name. */
58
+ export declare function isSourceName(value: unknown): value is string;
59
+ /** Whether `value` is a well-formed field name. */
60
+ export declare function isSourceFieldName(value: unknown): value is string;
61
+ /** One field as the host declares it. */
62
+ export interface SourceFieldDeclaration {
63
+ name: string;
64
+ type: SourceFieldType;
65
+ /** The closed values of an `enum` field. */
66
+ options?: readonly string[];
67
+ /**
68
+ * Filtering: `true` allows every operator of the type, a list allows only
69
+ * those, omitted or `false` allows none.
70
+ */
71
+ filter?: boolean | readonly SourceFilterOperator[];
72
+ /** Whether queries may sort by this field. Default `false`. */
73
+ sort?: boolean;
74
+ /** Whether create and update may set this field. Default `false`. The key never is. */
75
+ editable?: boolean;
76
+ /** Whether create needs a value for this field. Default `false`. */
77
+ required?: boolean;
78
+ }
79
+ /** A source's schema as the host declares it. */
80
+ export interface SourceSchemaDeclaration {
81
+ /** The field that identifies a row (text or number). Default `id`. */
82
+ key?: string;
83
+ fields: readonly SourceFieldDeclaration[];
84
+ operations: readonly SourceOperation[];
85
+ /** Default `none`. */
86
+ pagination?: SourcePaginationStyle;
87
+ /** The largest page a query may ask for, at most {@link SOURCE_LIMITS.pageSize}. Default 100. */
88
+ maxPageSize?: number;
89
+ /** The page size when a query names none. Default the smaller of 25 and `maxPageSize`. */
90
+ defaultPageSize?: number;
91
+ }
92
+ /** One field, normalized: every flag explicit, operators resolved. */
93
+ export interface SourceFieldDescriptor {
94
+ readonly name: string;
95
+ readonly type: SourceFieldType;
96
+ readonly options?: readonly string[];
97
+ readonly filter: readonly SourceFilterOperator[];
98
+ readonly sort: boolean;
99
+ readonly editable: boolean;
100
+ readonly required: boolean;
101
+ }
102
+ /**
103
+ * A source as plain JSON data: what validators (`@texuli/catalog`,
104
+ * `@texuli/spec`) and agents read. Never carries the adapter.
105
+ */
106
+ export interface SourceDescriptor {
107
+ readonly name: string;
108
+ readonly key: string;
109
+ readonly fields: readonly SourceFieldDescriptor[];
110
+ readonly operations: readonly SourceOperation[];
111
+ readonly pagination: SourcePaginationStyle;
112
+ readonly maxPageSize: number;
113
+ readonly defaultPageSize: number;
114
+ }
115
+ /**
116
+ * Check a host declaration and return its frozen descriptor. Throws a
117
+ * `TypeError` for a declaration mistake (an unknown type, a duplicate
118
+ * field, an operator the type does not take, a page size over the cap).
119
+ */
120
+ export declare function describeSource(name: string, declaration: SourceSchemaDeclaration): SourceDescriptor;
121
+ /** The descriptor's field named `name`, by own lookup (never the prototype chain). */
122
+ export declare function sourceField(descriptor: SourceDescriptor, name: string): SourceFieldDescriptor | undefined;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Data-source declarations (ADR-015): the host names each source, types its
3
+ * fields and declares what agents may ask of it. A blueprint can only name a
4
+ * declared source and query declared fields with the operators the field
5
+ * allows; nothing here builds a URL, a query string or code.
6
+ */
7
+ /** Field value types. The same five types as the `@texuli/data` grid (`GridValueType`). */
8
+ export const SOURCE_FIELD_TYPES = ['text', 'enum', 'number', 'date', 'boolean'];
9
+ /** What a source can do. `search` means `list` also accepts free search text. */
10
+ export const SOURCE_OPERATIONS = ['list', 'get', 'create', 'update', 'delete', 'search'];
11
+ /** How `list` pages: by an opaque cursor, by row offset, or not at all (one page). */
12
+ export const SOURCE_PAGINATION_STYLES = ['cursor', 'offset', 'none'];
13
+ /**
14
+ * Filter operators: exactly the `@texuli/data` grid filter model's
15
+ * `FILTER_OPERATORS`, so a query's filter is the same data the grid and the
16
+ * filter builder store.
17
+ */
18
+ export const SOURCE_FILTER_OPERATORS = [
19
+ 'eq',
20
+ 'neq',
21
+ 'contains',
22
+ 'in',
23
+ 'between',
24
+ 'before',
25
+ 'after',
26
+ 'isEmpty',
27
+ 'isNotEmpty',
28
+ ];
29
+ /** Operators that apply to each field type (the grid's `OPERATORS_BY_TYPE`). */
30
+ export const SOURCE_OPERATORS_BY_TYPE = Object.freeze({
31
+ text: Object.freeze(['eq', 'neq', 'contains', 'in', 'isEmpty', 'isNotEmpty']),
32
+ enum: Object.freeze(['eq', 'neq', 'in', 'isEmpty', 'isNotEmpty']),
33
+ number: Object.freeze(['eq', 'neq', 'in', 'between', 'isEmpty', 'isNotEmpty']),
34
+ date: Object.freeze([
35
+ 'eq',
36
+ 'neq',
37
+ 'between',
38
+ 'before',
39
+ 'after',
40
+ 'isEmpty',
41
+ 'isNotEmpty',
42
+ ]),
43
+ boolean: Object.freeze(['eq', 'neq', 'isEmpty', 'isNotEmpty']),
44
+ });
45
+ /** Every bound of the data-source vocabulary. Strings count UTF-16 units. */
46
+ export const SOURCE_LIMITS = Object.freeze({
47
+ /** Sources one registry holds. */
48
+ sources: 256,
49
+ /** Fields one source declares. */
50
+ fields: 64,
51
+ /** Options of an enum field. */
52
+ enumOptions: 100,
53
+ /** Fields one query may project. */
54
+ projection: 32,
55
+ /** Sort keys one query may carry. */
56
+ sort: 4,
57
+ /** Free search text. */
58
+ searchLength: 200,
59
+ /** Filter tree depth and node count (inside the grid model's 6 levels and 200 nodes). */
60
+ filterDepth: 6,
61
+ filterNodes: 64,
62
+ /** Values of an `in` list (the grid's limit). */
63
+ filterValues: 100,
64
+ /** A text value in a filter (the grid's limit). */
65
+ valueLength: 500,
66
+ /** The largest page any source may declare. */
67
+ pageSize: 500,
68
+ /** An opaque pagination cursor. */
69
+ cursorLength: 512,
70
+ /** Fields one create or update may change. */
71
+ changes: 64,
72
+ });
73
+ /** A source name: lowercase words joined by `.`, `_` or `-`, at most 64 characters. */
74
+ export const SOURCE_NAME_PATTERN = '^[a-z][a-z0-9]*([._-][a-z0-9]+)*$';
75
+ /** A field name: a letter, then letters, digits or `_`, at most 64 characters. */
76
+ export const SOURCE_FIELD_PATTERN = '^[A-Za-z][A-Za-z0-9_]*$';
77
+ const NAME_RE = new RegExp(SOURCE_NAME_PATTERN);
78
+ const FIELD_RE = new RegExp(SOURCE_FIELD_PATTERN);
79
+ /** Whether `value` is a well-formed source name. */
80
+ export function isSourceName(value) {
81
+ return typeof value === 'string' && value.length <= 64 && NAME_RE.test(value);
82
+ }
83
+ /** Whether `value` is a well-formed field name. */
84
+ export function isSourceFieldName(value) {
85
+ return typeof value === 'string' && value.length <= 64 && FIELD_RE.test(value);
86
+ }
87
+ /** A thrown declaration mistake: the host's code, never agent data, gets this. */
88
+ function declarationError(name, message) {
89
+ return new TypeError(`Data source "${name}": ${message}`);
90
+ }
91
+ function isPositiveInteger(value) {
92
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
93
+ }
94
+ /**
95
+ * Check a host declaration and return its frozen descriptor. Throws a
96
+ * `TypeError` for a declaration mistake (an unknown type, a duplicate
97
+ * field, an operator the type does not take, a page size over the cap).
98
+ */
99
+ export function describeSource(name, declaration) {
100
+ if (!isSourceName(name))
101
+ throw declarationError(String(name), 'the name is not a source name.');
102
+ const fields = Array.isArray(declaration.fields)
103
+ ? declaration.fields
104
+ : [];
105
+ if (fields.length === 0 || fields.length > SOURCE_LIMITS.fields) {
106
+ throw declarationError(name, `declare 1 to ${SOURCE_LIMITS.fields} fields.`);
107
+ }
108
+ const seen = new Set();
109
+ const out = [];
110
+ for (const field of fields) {
111
+ if (!isSourceFieldName(field?.name))
112
+ throw declarationError(name, 'a field name is malformed.');
113
+ if (seen.has(field.name))
114
+ throw declarationError(name, `"${field.name}" is declared twice.`);
115
+ seen.add(field.name);
116
+ if (!SOURCE_FIELD_TYPES.includes(field.type)) {
117
+ throw declarationError(name, `"${field.name}" has an unknown type.`);
118
+ }
119
+ const allowed = SOURCE_OPERATORS_BY_TYPE[field.type];
120
+ let filter = [];
121
+ if (field.filter === true)
122
+ filter = allowed;
123
+ else if (Array.isArray(field.filter)) {
124
+ const declared = field.filter;
125
+ for (const op of declared) {
126
+ if (!allowed.includes(op)) {
127
+ throw declarationError(name, `"${field.name}" (${field.type}) cannot filter by "${op}".`);
128
+ }
129
+ }
130
+ filter = Object.freeze([...new Set(declared)]);
131
+ }
132
+ let options;
133
+ if (field.type === 'enum') {
134
+ const declared = Array.isArray(field.options) ? field.options : [];
135
+ if (declared.length === 0 ||
136
+ declared.length > SOURCE_LIMITS.enumOptions ||
137
+ declared.some((o) => typeof o !== 'string' || o.length > SOURCE_LIMITS.valueLength)) {
138
+ throw declarationError(name, `"${field.name}" needs 1 to 100 string options.`);
139
+ }
140
+ options = Object.freeze([...declared]);
141
+ }
142
+ else if (field.options !== undefined) {
143
+ throw declarationError(name, `"${field.name}" is not an enum and takes no options.`);
144
+ }
145
+ out.push(Object.freeze({
146
+ name: field.name,
147
+ type: field.type,
148
+ ...(options ? { options } : {}),
149
+ filter,
150
+ sort: field.sort === true,
151
+ editable: field.editable === true,
152
+ required: field.required === true,
153
+ }));
154
+ }
155
+ const key = declaration.key ?? 'id';
156
+ const keyField = out.find((field) => field.name === key);
157
+ if (!keyField)
158
+ throw declarationError(name, `the key "${key}" is not a declared field.`);
159
+ if (keyField.type !== 'text' && keyField.type !== 'number') {
160
+ throw declarationError(name, 'the key must be a text or number field.');
161
+ }
162
+ if (keyField.editable)
163
+ throw declarationError(name, 'the key cannot be editable.');
164
+ const operations = declaration.operations;
165
+ if (!Array.isArray(operations) ||
166
+ operations.some((op) => !SOURCE_OPERATIONS.includes(op))) {
167
+ throw declarationError(name, 'operations must come from SOURCE_OPERATIONS.');
168
+ }
169
+ const pagination = declaration.pagination ?? 'none';
170
+ if (!SOURCE_PAGINATION_STYLES.includes(pagination)) {
171
+ throw declarationError(name, 'pagination must be cursor, offset or none.');
172
+ }
173
+ const maxPageSize = declaration.maxPageSize ?? 100;
174
+ if (!isPositiveInteger(maxPageSize) || maxPageSize > SOURCE_LIMITS.pageSize) {
175
+ throw declarationError(name, `maxPageSize must be 1 to ${SOURCE_LIMITS.pageSize}.`);
176
+ }
177
+ const defaultPageSize = declaration.defaultPageSize ?? Math.min(25, maxPageSize);
178
+ if (!isPositiveInteger(defaultPageSize) || defaultPageSize > maxPageSize) {
179
+ throw declarationError(name, 'defaultPageSize must be 1 to maxPageSize.');
180
+ }
181
+ return Object.freeze({
182
+ name,
183
+ key,
184
+ fields: Object.freeze(out),
185
+ operations: Object.freeze(SOURCE_OPERATIONS.filter((op) => operations.includes(op))),
186
+ pagination,
187
+ maxPageSize,
188
+ defaultPageSize,
189
+ });
190
+ }
191
+ /** The descriptor's field named `name`, by own lookup (never the prototype chain). */
192
+ export function sourceField(descriptor, name) {
193
+ for (const field of descriptor.fields)
194
+ if (field.name === name)
195
+ return field;
196
+ return undefined;
197
+ }
@@ -0,0 +1,17 @@
1
+ import type { Expression, ExpressionDiagnostic, ExpressionFunctionTable, ExpressionType } from './types.js';
2
+ export interface ExpressionAnalysisOptions {
3
+ /** Declared field types; when given, a field outside it is `unknown-field`. */
4
+ readonly fields?: Readonly<Record<string, ExpressionType>>;
5
+ /** The function table calls are typed against (default `CORE_FUNCTIONS`). */
6
+ readonly functions?: ExpressionFunctionTable;
7
+ /** The type the whole expression must have (`boolean` for a condition). */
8
+ readonly expect?: ExpressionType;
9
+ }
10
+ export interface ExpressionAnalysis {
11
+ readonly type: ExpressionType;
12
+ readonly diagnostics: readonly ExpressionDiagnostic[];
13
+ }
14
+ /** Whether a value of type `actual` can stand where `expected` is needed. */
15
+ export declare function isAssignable(actual: ExpressionType, expected: ExpressionType): boolean;
16
+ /** Infer an expression's type and report type and field problems. */
17
+ export declare function analyzeExpression(expression: Expression, options?: ExpressionAnalysisOptions): ExpressionAnalysis;
@@ -0,0 +1,151 @@
1
+ import { CORE_FUNCTIONS } from "./functions.js";
2
+ /** Whether a value of type `actual` can stand where `expected` is needed. */
3
+ export function isAssignable(actual, expected) {
4
+ if (expected === 'any' || actual === 'any' || actual === 'null' || actual === expected) {
5
+ return true;
6
+ }
7
+ // Dates are ISO strings: a string may be a date and a date reads as a string.
8
+ return ((actual === 'date' && expected === 'string') || (actual === 'string' && expected === 'date'));
9
+ }
10
+ const NUMERIC = ['number', 'boolean'];
11
+ const ORDERED = (a, b) => {
12
+ if (a === 'any' || b === 'any' || a === 'null' || b === 'null')
13
+ return true;
14
+ if (a === 'number' && b === 'number')
15
+ return true;
16
+ if (a === 'boolean' && b === 'boolean')
17
+ return true;
18
+ const textual = (t) => t === 'string' || t === 'date';
19
+ return textual(a) && textual(b);
20
+ };
21
+ /** Infer an expression's type and report type and field problems. */
22
+ export function analyzeExpression(expression, options = {}) {
23
+ const functions = options.functions ?? CORE_FUNCTIONS;
24
+ const fields = options.fields;
25
+ const diagnostics = [];
26
+ const report = (path, message, code = 'type') => {
27
+ if (diagnostics.length < 20)
28
+ diagnostics.push(Object.freeze({ code, path, message }));
29
+ };
30
+ const need = (type, allowed, path, what) => {
31
+ if (type === 'any' || type === 'null' || allowed.includes(type))
32
+ return;
33
+ report(path, `${what} needs ${allowed.join(' or ')}, not ${type}.`);
34
+ };
35
+ const visit = (node, path) => {
36
+ switch (node.op) {
37
+ case 'num':
38
+ return 'number';
39
+ case 'str':
40
+ return 'string';
41
+ case 'bool':
42
+ return 'boolean';
43
+ case 'null':
44
+ return 'null';
45
+ case 'var':
46
+ case 'field': {
47
+ if (!fields)
48
+ return 'any';
49
+ if (!Object.hasOwn(fields, node.name)) {
50
+ report(`${path}.name`, `There is no field "${node.name}".`, 'unknown-field');
51
+ return 'any';
52
+ }
53
+ return fields[node.name];
54
+ }
55
+ case 'ref':
56
+ return 'any';
57
+ case 'range':
58
+ return 'list';
59
+ case 'add':
60
+ case 'sub':
61
+ case 'mul':
62
+ case 'div':
63
+ case 'mod':
64
+ need(visit(node.left, `${path}.left`), NUMERIC, `${path}.left`, `"${node.op}"`);
65
+ need(visit(node.right, `${path}.right`), NUMERIC, `${path}.right`, `"${node.op}"`);
66
+ return 'number';
67
+ case 'pow':
68
+ need(visit(node.base, `${path}.base`), NUMERIC, `${path}.base`, '"pow"');
69
+ need(visit(node.exponent, `${path}.exponent`), NUMERIC, `${path}.exponent`, '"pow"');
70
+ return 'number';
71
+ case 'neg':
72
+ need(visit(node.value, `${path}.value`), NUMERIC, `${path}.value`, '"neg"');
73
+ return 'number';
74
+ case 'eq':
75
+ case 'neq':
76
+ visit(node.left, `${path}.left`);
77
+ visit(node.right, `${path}.right`);
78
+ return 'boolean';
79
+ case 'lt':
80
+ case 'lte':
81
+ case 'gt':
82
+ case 'gte': {
83
+ const a = visit(node.left, `${path}.left`);
84
+ const b = visit(node.right, `${path}.right`);
85
+ if (!ORDERED(a, b))
86
+ report(path, `"${node.op}" cannot order ${a} and ${b}.`);
87
+ return 'boolean';
88
+ }
89
+ case 'and':
90
+ case 'or':
91
+ node.args.forEach((arg, index) => {
92
+ const at = `${path}.args[${index}]`;
93
+ need(visit(arg, at), ['boolean', 'number'], at, `"${node.op}"`);
94
+ });
95
+ return 'boolean';
96
+ case 'not':
97
+ need(visit(node.value, `${path}.value`), ['boolean', 'number'], `${path}.value`, '"not"');
98
+ return 'boolean';
99
+ case 'if': {
100
+ need(visit(node.test, `${path}.test`), ['boolean', 'number'], `${path}.test`, '"if"');
101
+ const a = visit(node.ifTrue, `${path}.ifTrue`);
102
+ const b = visit(node.ifFalse, `${path}.ifFalse`);
103
+ if (a === b || b === 'null')
104
+ return a;
105
+ return a === 'null' ? b : 'any';
106
+ }
107
+ case 'min':
108
+ case 'max':
109
+ node.args.forEach((arg, index) => {
110
+ const at = `${path}.args[${index}]`;
111
+ need(visit(arg, at), ['number', 'boolean', 'list'], at, `"${node.op}"`);
112
+ });
113
+ return 'number';
114
+ case 'concat':
115
+ for (const [index, arg] of node.args.entries())
116
+ visit(arg, `${path}.args[${index}]`);
117
+ return 'string';
118
+ case 'round':
119
+ need(visit(node.value, `${path}.value`), NUMERIC, `${path}.value`, '"round"');
120
+ return 'number';
121
+ case 'clamp':
122
+ need(visit(node.value, `${path}.value`), NUMERIC, `${path}.value`, '"clamp"');
123
+ need(visit(node.min, `${path}.min`), NUMERIC, `${path}.min`, '"clamp"');
124
+ need(visit(node.max, `${path}.max`), NUMERIC, `${path}.max`, '"clamp"');
125
+ return 'number';
126
+ case 'call': {
127
+ const fn = Object.hasOwn(functions, node.fn) ? functions[node.fn] : undefined;
128
+ node.args.forEach((arg, index) => {
129
+ const at = `${path}.args[${index}]`;
130
+ const type = visit(arg, at);
131
+ if (!fn || fn.params.length === 0)
132
+ return;
133
+ const expected = fn.params[Math.min(index, fn.params.length - 1)];
134
+ if (!isAssignable(type, expected)) {
135
+ report(at, `"${node.fn}" argument ${index + 1} needs ${expected}, not ${type}.`);
136
+ }
137
+ });
138
+ return fn?.returns ?? 'any';
139
+ }
140
+ default:
141
+ return 'any';
142
+ }
143
+ };
144
+ const type = visit(expression.ast, '$');
145
+ if (options.expect && !isAssignable(type, options.expect)) {
146
+ if (!(options.expect === 'boolean' && type === 'number')) {
147
+ report('$', `The expression is ${type}; ${options.expect} is needed here.`);
148
+ }
149
+ }
150
+ return Object.freeze({ type, diagnostics: Object.freeze(diagnostics) });
151
+ }
@@ -0,0 +1,55 @@
1
+ import { type Expression, type ExpressionFunctionTable, type ExpressionValue } from './types.js';
2
+ /**
3
+ * A step budget shared by every expression of one recalculation pass
4
+ * (default `EXPRESSION_LIMITS.passSteps`, 10,000,000). Once spent, every
5
+ * further evaluation in the pass is `#LIMIT!`.
6
+ */
7
+ export interface ExpressionBudget {
8
+ readonly limit: number;
9
+ /** Steps spent so far. */
10
+ readonly used: number;
11
+ readonly exhausted: boolean;
12
+ /** Spend steps; returns false (and marks the budget exhausted) past the limit. */
13
+ spend(steps: number): boolean;
14
+ }
15
+ /** Make a budget for one recalculation pass. */
16
+ export declare function createExpressionBudget(limit?: number): ExpressionBudget;
17
+ /**
18
+ * Where an expression reads its inputs. Every method is optional; a
19
+ * missing `field` reads `null`, a missing `ref` or `range` is `#REF!`.
20
+ * Returned values are normalized: non-finite numbers become `null`,
21
+ * strings and lists past their caps become `#LIMIT!`, anything else that
22
+ * is not a scalar, a flat list or an error value becomes `#VALUE!`.
23
+ */
24
+ export interface ExpressionEnv {
25
+ /** A `field` or `var` value by name. */
26
+ field?(name: string): unknown;
27
+ /** A dialect reference (`ref` node), such as a sheet cell. */
28
+ ref?(ref: string): unknown;
29
+ /** A dialect interval (`range` node) as a flat list, such as `A1:B3`. */
30
+ range?(from: string, to: string): unknown;
31
+ /** The host clock for `today()` (milliseconds or a Date). */
32
+ now?(): number | Date;
33
+ }
34
+ export interface ExpressionEvaluateOptions {
35
+ /** The closed function table (default `CORE_FUNCTIONS`); unknown names are `#NAME?`. */
36
+ readonly functions?: ExpressionFunctionTable;
37
+ /** The recalculation pass this evaluation belongs to. */
38
+ readonly budget?: ExpressionBudget;
39
+ /** Steps for this expression (default and maximum `EXPRESSION_LIMITS.steps`). */
40
+ readonly steps?: number;
41
+ /**
42
+ * How empty (`null`) operands behave in arithmetic and comparisons:
43
+ * `null` (default, forms) keeps them unknown; `zero` (spreadsheets) reads
44
+ * them as 0 or the empty string.
45
+ */
46
+ readonly blank?: 'null' | 'zero';
47
+ /** Compare strings ignoring case (spreadsheet `=` semantics). Default false. */
48
+ readonly caseInsensitive?: boolean;
49
+ }
50
+ /**
51
+ * Evaluate a validated expression (from `parseExpression` or
52
+ * `parseExpressionText`). Never throws: errors are values, and running past
53
+ * the per-expression or per-pass step limit is `#LIMIT!`.
54
+ */
55
+ export declare function evaluateExpression(expression: Expression, env?: ExpressionEnv, options?: ExpressionEvaluateOptions): ExpressionValue;