@the-trybe/formula-engine 1.0.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/.claude/settings.local.json +6 -0
- package/PRD_FORMULA_ENGINE.md +1863 -0
- package/README.md +382 -0
- package/dist/decimal-utils.d.ts +180 -0
- package/dist/decimal-utils.js +355 -0
- package/dist/dependency-extractor.d.ts +20 -0
- package/dist/dependency-extractor.js +103 -0
- package/dist/dependency-graph.d.ts +60 -0
- package/dist/dependency-graph.js +252 -0
- package/dist/errors.d.ts +161 -0
- package/dist/errors.js +260 -0
- package/dist/evaluator.d.ts +51 -0
- package/dist/evaluator.js +494 -0
- package/dist/formula-engine.d.ts +79 -0
- package/dist/formula-engine.js +355 -0
- package/dist/functions.d.ts +3 -0
- package/dist/functions.js +720 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +61 -0
- package/dist/lexer.d.ts +25 -0
- package/dist/lexer.js +357 -0
- package/dist/parser.d.ts +32 -0
- package/dist/parser.js +372 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.js +62 -0
- package/jest.config.js +23 -0
- package/package.json +35 -0
- package/src/decimal-utils.ts +408 -0
- package/src/dependency-extractor.ts +117 -0
- package/src/dependency-graph.test.ts +238 -0
- package/src/dependency-graph.ts +288 -0
- package/src/errors.ts +296 -0
- package/src/evaluator.ts +604 -0
- package/src/formula-engine.test.ts +660 -0
- package/src/formula-engine.ts +430 -0
- package/src/functions.ts +770 -0
- package/src/index.ts +103 -0
- package/src/lexer.test.ts +288 -0
- package/src/lexer.ts +394 -0
- package/src/parser.test.ts +349 -0
- package/src/parser.ts +449 -0
- package/src/types.ts +347 -0
- package/tsconfig.json +29 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { Lexer } from './lexer';
|
|
2
|
+
import { Token, TokenType, ASTNode } from './types';
|
|
3
|
+
import { SyntaxError, UnexpectedTokenError } from './errors';
|
|
4
|
+
|
|
5
|
+
// Operator precedence levels (higher = tighter binding)
|
|
6
|
+
const PRECEDENCE = {
|
|
7
|
+
LOWEST: 1,
|
|
8
|
+
TERNARY: 2, // ? :
|
|
9
|
+
OR: 3, // || OR
|
|
10
|
+
AND: 4, // && AND
|
|
11
|
+
EQUALITY: 5, // == !=
|
|
12
|
+
COMPARISON: 6, // < > <= >=
|
|
13
|
+
TERM: 7, // + -
|
|
14
|
+
FACTOR: 8, // * / %
|
|
15
|
+
POWER: 9, // ^
|
|
16
|
+
UNARY: 10, // - ! NOT
|
|
17
|
+
CALL: 11, // function calls
|
|
18
|
+
MEMBER: 12, // . []
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class Parser {
|
|
22
|
+
private tokens: Token[] = [];
|
|
23
|
+
private current: number = 0;
|
|
24
|
+
private expression: string = '';
|
|
25
|
+
|
|
26
|
+
parse(expression: string): ASTNode {
|
|
27
|
+
this.expression = expression;
|
|
28
|
+
const lexer = new Lexer(expression);
|
|
29
|
+
this.tokens = lexer.tokenize();
|
|
30
|
+
this.current = 0;
|
|
31
|
+
|
|
32
|
+
const ast = this.parseExpression(PRECEDENCE.LOWEST);
|
|
33
|
+
|
|
34
|
+
if (!this.isAtEnd()) {
|
|
35
|
+
const token = this.peek();
|
|
36
|
+
throw new UnexpectedTokenError(
|
|
37
|
+
String(token.value),
|
|
38
|
+
['end of expression'],
|
|
39
|
+
token.position
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return ast;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private parseExpression(precedence: number): ASTNode {
|
|
47
|
+
let left = this.parsePrefixExpression();
|
|
48
|
+
|
|
49
|
+
while (!this.isAtEnd() && precedence < this.getPrecedence()) {
|
|
50
|
+
left = this.parseInfixExpression(left);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return left;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private parsePrefixExpression(): ASTNode {
|
|
57
|
+
const token = this.peek();
|
|
58
|
+
|
|
59
|
+
switch (token.type) {
|
|
60
|
+
case TokenType.NUMBER:
|
|
61
|
+
return this.parseNumber();
|
|
62
|
+
case TokenType.STRING:
|
|
63
|
+
return this.parseString();
|
|
64
|
+
case TokenType.BOOLEAN:
|
|
65
|
+
return this.parseBoolean();
|
|
66
|
+
case TokenType.NULL:
|
|
67
|
+
return this.parseNull();
|
|
68
|
+
case TokenType.VARIABLE:
|
|
69
|
+
return this.parseVariable();
|
|
70
|
+
case TokenType.CONTEXT_VAR:
|
|
71
|
+
return this.parseContextVariable();
|
|
72
|
+
case TokenType.IDENTIFIER:
|
|
73
|
+
return this.parseIdentifierOrFunctionCall();
|
|
74
|
+
case TokenType.LPAREN:
|
|
75
|
+
return this.parseGroupedExpression();
|
|
76
|
+
case TokenType.LBRACKET:
|
|
77
|
+
return this.parseArrayLiteral();
|
|
78
|
+
case TokenType.MINUS:
|
|
79
|
+
case TokenType.NOT:
|
|
80
|
+
return this.parseUnaryExpression();
|
|
81
|
+
default:
|
|
82
|
+
throw new UnexpectedTokenError(
|
|
83
|
+
String(token.value),
|
|
84
|
+
['number', 'string', 'boolean', 'null', 'variable', 'identifier', '(', '[', '-', '!'],
|
|
85
|
+
token.position
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private parseInfixExpression(left: ASTNode): ASTNode {
|
|
91
|
+
const token = this.peek();
|
|
92
|
+
|
|
93
|
+
switch (token.type) {
|
|
94
|
+
case TokenType.PLUS:
|
|
95
|
+
case TokenType.MINUS:
|
|
96
|
+
case TokenType.MULTIPLY:
|
|
97
|
+
case TokenType.DIVIDE:
|
|
98
|
+
case TokenType.MODULO:
|
|
99
|
+
case TokenType.POWER:
|
|
100
|
+
case TokenType.EQ:
|
|
101
|
+
case TokenType.NEQ:
|
|
102
|
+
case TokenType.LT:
|
|
103
|
+
case TokenType.GT:
|
|
104
|
+
case TokenType.LTE:
|
|
105
|
+
case TokenType.GTE:
|
|
106
|
+
case TokenType.AND:
|
|
107
|
+
case TokenType.OR:
|
|
108
|
+
return this.parseBinaryExpression(left);
|
|
109
|
+
case TokenType.QUESTION:
|
|
110
|
+
return this.parseTernaryExpression(left);
|
|
111
|
+
case TokenType.DOT:
|
|
112
|
+
return this.parseMemberAccess(left);
|
|
113
|
+
case TokenType.LBRACKET:
|
|
114
|
+
return this.parseIndexAccess(left);
|
|
115
|
+
case TokenType.LPAREN:
|
|
116
|
+
// This handles the case where we have an identifier followed by (
|
|
117
|
+
// But actually this should be handled in parseIdentifierOrFunctionCall
|
|
118
|
+
return left;
|
|
119
|
+
default:
|
|
120
|
+
return left;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private parseNumber(): ASTNode {
|
|
125
|
+
const token = this.advance();
|
|
126
|
+
const value = token.value;
|
|
127
|
+
|
|
128
|
+
if (typeof value === 'number') {
|
|
129
|
+
// It's a float
|
|
130
|
+
return {
|
|
131
|
+
type: 'NumberLiteral',
|
|
132
|
+
value: value,
|
|
133
|
+
};
|
|
134
|
+
} else {
|
|
135
|
+
// It's a decimal (stored as string)
|
|
136
|
+
return {
|
|
137
|
+
type: 'DecimalLiteral',
|
|
138
|
+
value: String(value),
|
|
139
|
+
raw: String(value),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private parseString(): ASTNode {
|
|
145
|
+
const token = this.advance();
|
|
146
|
+
return {
|
|
147
|
+
type: 'StringLiteral',
|
|
148
|
+
value: String(token.value),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private parseBoolean(): ASTNode {
|
|
153
|
+
const token = this.advance();
|
|
154
|
+
return {
|
|
155
|
+
type: 'BooleanLiteral',
|
|
156
|
+
value: token.value === true,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private parseNull(): ASTNode {
|
|
161
|
+
this.advance();
|
|
162
|
+
return {
|
|
163
|
+
type: 'NullLiteral',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private parseVariable(): ASTNode {
|
|
168
|
+
const token = this.advance();
|
|
169
|
+
return {
|
|
170
|
+
type: 'VariableReference',
|
|
171
|
+
prefix: '$',
|
|
172
|
+
name: String(token.value),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private parseContextVariable(): ASTNode {
|
|
177
|
+
const token = this.advance();
|
|
178
|
+
return {
|
|
179
|
+
type: 'VariableReference',
|
|
180
|
+
prefix: '@',
|
|
181
|
+
name: String(token.value),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private parseIdentifierOrFunctionCall(): ASTNode {
|
|
186
|
+
const token = this.advance();
|
|
187
|
+
const name = String(token.value);
|
|
188
|
+
|
|
189
|
+
// Check if it's a function call
|
|
190
|
+
if (this.peek().type === TokenType.LPAREN) {
|
|
191
|
+
return this.parseFunctionCall(name);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Otherwise, check for keywords AND/OR/NOT used as standalone identifiers
|
|
195
|
+
const upperName = name.toUpperCase();
|
|
196
|
+
if (upperName === 'AND' || upperName === 'OR') {
|
|
197
|
+
// These should be handled as operators, but if we reach here,
|
|
198
|
+
// it means they were used in an invalid context
|
|
199
|
+
throw new SyntaxError(
|
|
200
|
+
`'${name}' cannot be used as an identifier`,
|
|
201
|
+
token.position,
|
|
202
|
+
token.line,
|
|
203
|
+
token.column,
|
|
204
|
+
this.expression
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// It's a bare identifier - could be a variable without prefix
|
|
209
|
+
// For now, treat it as an error - require explicit prefix
|
|
210
|
+
throw new SyntaxError(
|
|
211
|
+
`Unknown identifier '${name}'. Variables must be prefixed with $ or @`,
|
|
212
|
+
token.position,
|
|
213
|
+
token.line,
|
|
214
|
+
token.column,
|
|
215
|
+
this.expression
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private parseFunctionCall(name: string): ASTNode {
|
|
220
|
+
this.advance(); // consume '('
|
|
221
|
+
const args: ASTNode[] = [];
|
|
222
|
+
|
|
223
|
+
if (this.peek().type !== TokenType.RPAREN) {
|
|
224
|
+
do {
|
|
225
|
+
if (this.peek().type === TokenType.COMMA) {
|
|
226
|
+
this.advance();
|
|
227
|
+
}
|
|
228
|
+
args.push(this.parseExpression(PRECEDENCE.LOWEST));
|
|
229
|
+
} while (this.peek().type === TokenType.COMMA);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this.expect(TokenType.RPAREN, ')');
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
type: 'FunctionCall',
|
|
236
|
+
name: name.toUpperCase(), // Functions are case-insensitive
|
|
237
|
+
arguments: args,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private parseGroupedExpression(): ASTNode {
|
|
242
|
+
this.advance(); // consume '('
|
|
243
|
+
const expr = this.parseExpression(PRECEDENCE.LOWEST);
|
|
244
|
+
this.expect(TokenType.RPAREN, ')');
|
|
245
|
+
return expr;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private parseArrayLiteral(): ASTNode {
|
|
249
|
+
this.advance(); // consume '['
|
|
250
|
+
const elements: ASTNode[] = [];
|
|
251
|
+
|
|
252
|
+
if (this.peek().type !== TokenType.RBRACKET) {
|
|
253
|
+
do {
|
|
254
|
+
if (this.peek().type === TokenType.COMMA) {
|
|
255
|
+
this.advance();
|
|
256
|
+
}
|
|
257
|
+
elements.push(this.parseExpression(PRECEDENCE.LOWEST));
|
|
258
|
+
} while (this.peek().type === TokenType.COMMA);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.expect(TokenType.RBRACKET, ']');
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
type: 'ArrayLiteral',
|
|
265
|
+
elements,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private parseUnaryExpression(): ASTNode {
|
|
270
|
+
const token = this.advance();
|
|
271
|
+
const operator = this.getOperatorSymbol(token.type);
|
|
272
|
+
const operand = this.parseExpression(PRECEDENCE.UNARY);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
type: 'UnaryOperation',
|
|
276
|
+
operator,
|
|
277
|
+
operand,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private parseBinaryExpression(left: ASTNode): ASTNode {
|
|
282
|
+
const token = this.advance();
|
|
283
|
+
const operator = this.getOperatorSymbol(token.type);
|
|
284
|
+
const precedence = this.getTokenPrecedence(token.type);
|
|
285
|
+
|
|
286
|
+
// Right associativity for power operator
|
|
287
|
+
const nextPrecedence = token.type === TokenType.POWER ? precedence - 1 : precedence;
|
|
288
|
+
const right = this.parseExpression(nextPrecedence);
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
type: 'BinaryOperation',
|
|
292
|
+
operator,
|
|
293
|
+
left,
|
|
294
|
+
right,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private parseTernaryExpression(condition: ASTNode): ASTNode {
|
|
299
|
+
this.advance(); // consume '?'
|
|
300
|
+
const consequent = this.parseExpression(PRECEDENCE.LOWEST);
|
|
301
|
+
this.expect(TokenType.COLON, ':');
|
|
302
|
+
const alternate = this.parseExpression(PRECEDENCE.TERNARY - 1);
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
type: 'ConditionalExpression',
|
|
306
|
+
condition,
|
|
307
|
+
consequent,
|
|
308
|
+
alternate,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private parseMemberAccess(object: ASTNode): ASTNode {
|
|
313
|
+
this.advance(); // consume '.'
|
|
314
|
+
const token = this.peek();
|
|
315
|
+
|
|
316
|
+
if (token.type !== TokenType.IDENTIFIER && token.type !== TokenType.VARIABLE) {
|
|
317
|
+
throw new UnexpectedTokenError(
|
|
318
|
+
String(token.value),
|
|
319
|
+
['identifier'],
|
|
320
|
+
token.position
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.advance();
|
|
325
|
+
const property = String(token.value);
|
|
326
|
+
|
|
327
|
+
const node: ASTNode = {
|
|
328
|
+
type: 'MemberAccess',
|
|
329
|
+
object,
|
|
330
|
+
property,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Check for chained access
|
|
334
|
+
if (this.peek().type === TokenType.DOT) {
|
|
335
|
+
return this.parseMemberAccess(node);
|
|
336
|
+
}
|
|
337
|
+
if (this.peek().type === TokenType.LBRACKET) {
|
|
338
|
+
return this.parseIndexAccess(node);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return node;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private parseIndexAccess(object: ASTNode): ASTNode {
|
|
345
|
+
this.advance(); // consume '['
|
|
346
|
+
const index = this.parseExpression(PRECEDENCE.LOWEST);
|
|
347
|
+
this.expect(TokenType.RBRACKET, ']');
|
|
348
|
+
|
|
349
|
+
const node: ASTNode = {
|
|
350
|
+
type: 'IndexAccess',
|
|
351
|
+
object,
|
|
352
|
+
index,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// Check for chained access
|
|
356
|
+
if (this.peek().type === TokenType.DOT) {
|
|
357
|
+
return this.parseMemberAccess(node);
|
|
358
|
+
}
|
|
359
|
+
if (this.peek().type === TokenType.LBRACKET) {
|
|
360
|
+
return this.parseIndexAccess(node);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return node;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private getPrecedence(): number {
|
|
367
|
+
return this.getTokenPrecedence(this.peek().type);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private getTokenPrecedence(type: TokenType): number {
|
|
371
|
+
switch (type) {
|
|
372
|
+
case TokenType.OR:
|
|
373
|
+
return PRECEDENCE.OR;
|
|
374
|
+
case TokenType.AND:
|
|
375
|
+
return PRECEDENCE.AND;
|
|
376
|
+
case TokenType.EQ:
|
|
377
|
+
case TokenType.NEQ:
|
|
378
|
+
return PRECEDENCE.EQUALITY;
|
|
379
|
+
case TokenType.LT:
|
|
380
|
+
case TokenType.GT:
|
|
381
|
+
case TokenType.LTE:
|
|
382
|
+
case TokenType.GTE:
|
|
383
|
+
return PRECEDENCE.COMPARISON;
|
|
384
|
+
case TokenType.PLUS:
|
|
385
|
+
case TokenType.MINUS:
|
|
386
|
+
return PRECEDENCE.TERM;
|
|
387
|
+
case TokenType.MULTIPLY:
|
|
388
|
+
case TokenType.DIVIDE:
|
|
389
|
+
case TokenType.MODULO:
|
|
390
|
+
return PRECEDENCE.FACTOR;
|
|
391
|
+
case TokenType.POWER:
|
|
392
|
+
return PRECEDENCE.POWER;
|
|
393
|
+
case TokenType.DOT:
|
|
394
|
+
case TokenType.LBRACKET:
|
|
395
|
+
return PRECEDENCE.MEMBER;
|
|
396
|
+
case TokenType.QUESTION:
|
|
397
|
+
return PRECEDENCE.TERNARY;
|
|
398
|
+
default:
|
|
399
|
+
return PRECEDENCE.LOWEST;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private getOperatorSymbol(type: TokenType): string {
|
|
404
|
+
switch (type) {
|
|
405
|
+
case TokenType.PLUS: return '+';
|
|
406
|
+
case TokenType.MINUS: return '-';
|
|
407
|
+
case TokenType.MULTIPLY: return '*';
|
|
408
|
+
case TokenType.DIVIDE: return '/';
|
|
409
|
+
case TokenType.MODULO: return '%';
|
|
410
|
+
case TokenType.POWER: return '^';
|
|
411
|
+
case TokenType.EQ: return '==';
|
|
412
|
+
case TokenType.NEQ: return '!=';
|
|
413
|
+
case TokenType.LT: return '<';
|
|
414
|
+
case TokenType.GT: return '>';
|
|
415
|
+
case TokenType.LTE: return '<=';
|
|
416
|
+
case TokenType.GTE: return '>=';
|
|
417
|
+
case TokenType.AND: return '&&';
|
|
418
|
+
case TokenType.OR: return '||';
|
|
419
|
+
case TokenType.NOT: return '!';
|
|
420
|
+
default: return '';
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private peek(): Token {
|
|
425
|
+
return this.tokens[this.current];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private advance(): Token {
|
|
429
|
+
if (!this.isAtEnd()) {
|
|
430
|
+
this.current++;
|
|
431
|
+
}
|
|
432
|
+
return this.tokens[this.current - 1];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private isAtEnd(): boolean {
|
|
436
|
+
return this.peek().type === TokenType.EOF;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private expect(type: TokenType, expected: string): Token {
|
|
440
|
+
if (this.peek().type === type) {
|
|
441
|
+
return this.advance();
|
|
442
|
+
}
|
|
443
|
+
throw new UnexpectedTokenError(
|
|
444
|
+
String(this.peek().value),
|
|
445
|
+
[expected],
|
|
446
|
+
this.peek().position
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|