@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/lexer.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { Token, TokenType } from './types';
|
|
2
|
+
import { SyntaxError, UnterminatedStringError, InvalidNumberError } from './errors';
|
|
3
|
+
|
|
4
|
+
const KEYWORDS: Record<string, TokenType> = {
|
|
5
|
+
'true': TokenType.BOOLEAN,
|
|
6
|
+
'false': TokenType.BOOLEAN,
|
|
7
|
+
'null': TokenType.NULL,
|
|
8
|
+
'AND': TokenType.AND,
|
|
9
|
+
'OR': TokenType.OR,
|
|
10
|
+
'NOT': TokenType.NOT,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class Lexer {
|
|
14
|
+
private input: string;
|
|
15
|
+
private position: number = 0;
|
|
16
|
+
private line: number = 1;
|
|
17
|
+
private column: number = 1;
|
|
18
|
+
private tokens: Token[] = [];
|
|
19
|
+
|
|
20
|
+
constructor(input: string) {
|
|
21
|
+
this.input = input;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
tokenize(): Token[] {
|
|
25
|
+
this.tokens = [];
|
|
26
|
+
this.position = 0;
|
|
27
|
+
this.line = 1;
|
|
28
|
+
this.column = 1;
|
|
29
|
+
|
|
30
|
+
while (!this.isAtEnd()) {
|
|
31
|
+
this.skipWhitespace();
|
|
32
|
+
if (!this.isAtEnd()) {
|
|
33
|
+
this.scanToken();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.tokens.push({
|
|
38
|
+
type: TokenType.EOF,
|
|
39
|
+
value: null,
|
|
40
|
+
position: this.position,
|
|
41
|
+
line: this.line,
|
|
42
|
+
column: this.column,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return this.tokens;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private isAtEnd(): boolean {
|
|
49
|
+
return this.position >= this.input.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private peek(): string {
|
|
53
|
+
if (this.isAtEnd()) return '\0';
|
|
54
|
+
return this.input[this.position];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private peekNext(): string {
|
|
58
|
+
if (this.position + 1 >= this.input.length) return '\0';
|
|
59
|
+
return this.input[this.position + 1];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private advance(): string {
|
|
63
|
+
const char = this.input[this.position++];
|
|
64
|
+
if (char === '\n') {
|
|
65
|
+
this.line++;
|
|
66
|
+
this.column = 1;
|
|
67
|
+
} else {
|
|
68
|
+
this.column++;
|
|
69
|
+
}
|
|
70
|
+
return char;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private skipWhitespace(): void {
|
|
74
|
+
while (!this.isAtEnd()) {
|
|
75
|
+
const char = this.peek();
|
|
76
|
+
if (char === ' ' || char === '\t' || char === '\r' || char === '\n') {
|
|
77
|
+
this.advance();
|
|
78
|
+
} else {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private addToken(type: TokenType, value: string | number | boolean | null): void {
|
|
85
|
+
this.tokens.push({
|
|
86
|
+
type,
|
|
87
|
+
value,
|
|
88
|
+
position: this.position,
|
|
89
|
+
line: this.line,
|
|
90
|
+
column: this.column,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private scanToken(): void {
|
|
95
|
+
const startPosition = this.position;
|
|
96
|
+
const startLine = this.line;
|
|
97
|
+
const startColumn = this.column;
|
|
98
|
+
const char = this.advance();
|
|
99
|
+
|
|
100
|
+
switch (char) {
|
|
101
|
+
case '(':
|
|
102
|
+
this.addToken(TokenType.LPAREN, '(');
|
|
103
|
+
break;
|
|
104
|
+
case ')':
|
|
105
|
+
this.addToken(TokenType.RPAREN, ')');
|
|
106
|
+
break;
|
|
107
|
+
case '[':
|
|
108
|
+
this.addToken(TokenType.LBRACKET, '[');
|
|
109
|
+
break;
|
|
110
|
+
case ']':
|
|
111
|
+
this.addToken(TokenType.RBRACKET, ']');
|
|
112
|
+
break;
|
|
113
|
+
case ',':
|
|
114
|
+
this.addToken(TokenType.COMMA, ',');
|
|
115
|
+
break;
|
|
116
|
+
case '.':
|
|
117
|
+
this.addToken(TokenType.DOT, '.');
|
|
118
|
+
break;
|
|
119
|
+
case '?':
|
|
120
|
+
this.addToken(TokenType.QUESTION, '?');
|
|
121
|
+
break;
|
|
122
|
+
case ':':
|
|
123
|
+
this.addToken(TokenType.COLON, ':');
|
|
124
|
+
break;
|
|
125
|
+
case '+':
|
|
126
|
+
this.addToken(TokenType.PLUS, '+');
|
|
127
|
+
break;
|
|
128
|
+
case '-':
|
|
129
|
+
this.addToken(TokenType.MINUS, '-');
|
|
130
|
+
break;
|
|
131
|
+
case '*':
|
|
132
|
+
this.addToken(TokenType.MULTIPLY, '*');
|
|
133
|
+
break;
|
|
134
|
+
case '/':
|
|
135
|
+
this.addToken(TokenType.DIVIDE, '/');
|
|
136
|
+
break;
|
|
137
|
+
case '%':
|
|
138
|
+
this.addToken(TokenType.MODULO, '%');
|
|
139
|
+
break;
|
|
140
|
+
case '^':
|
|
141
|
+
this.addToken(TokenType.POWER, '^');
|
|
142
|
+
break;
|
|
143
|
+
case '=':
|
|
144
|
+
if (this.peek() === '=') {
|
|
145
|
+
this.advance();
|
|
146
|
+
this.addToken(TokenType.EQ, '==');
|
|
147
|
+
} else {
|
|
148
|
+
throw new SyntaxError(
|
|
149
|
+
`Unexpected character '${char}'`,
|
|
150
|
+
startPosition,
|
|
151
|
+
startLine,
|
|
152
|
+
startColumn,
|
|
153
|
+
this.input
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
case '!':
|
|
158
|
+
if (this.peek() === '=') {
|
|
159
|
+
this.advance();
|
|
160
|
+
this.addToken(TokenType.NEQ, '!=');
|
|
161
|
+
} else {
|
|
162
|
+
this.addToken(TokenType.NOT, '!');
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
case '<':
|
|
166
|
+
if (this.peek() === '=') {
|
|
167
|
+
this.advance();
|
|
168
|
+
this.addToken(TokenType.LTE, '<=');
|
|
169
|
+
} else {
|
|
170
|
+
this.addToken(TokenType.LT, '<');
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
case '>':
|
|
174
|
+
if (this.peek() === '=') {
|
|
175
|
+
this.advance();
|
|
176
|
+
this.addToken(TokenType.GTE, '>=');
|
|
177
|
+
} else {
|
|
178
|
+
this.addToken(TokenType.GT, '>');
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
case '&':
|
|
182
|
+
if (this.peek() === '&') {
|
|
183
|
+
this.advance();
|
|
184
|
+
this.addToken(TokenType.AND, '&&');
|
|
185
|
+
} else {
|
|
186
|
+
throw new SyntaxError(
|
|
187
|
+
`Unexpected character '${char}'`,
|
|
188
|
+
startPosition,
|
|
189
|
+
startLine,
|
|
190
|
+
startColumn,
|
|
191
|
+
this.input
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
case '|':
|
|
196
|
+
if (this.peek() === '|') {
|
|
197
|
+
this.advance();
|
|
198
|
+
this.addToken(TokenType.OR, '||');
|
|
199
|
+
} else {
|
|
200
|
+
throw new SyntaxError(
|
|
201
|
+
`Unexpected character '${char}'`,
|
|
202
|
+
startPosition,
|
|
203
|
+
startLine,
|
|
204
|
+
startColumn,
|
|
205
|
+
this.input
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
case '$':
|
|
210
|
+
this.scanVariable();
|
|
211
|
+
break;
|
|
212
|
+
case '@':
|
|
213
|
+
this.scanContextVariable();
|
|
214
|
+
break;
|
|
215
|
+
case '"':
|
|
216
|
+
case "'":
|
|
217
|
+
this.scanString(char);
|
|
218
|
+
break;
|
|
219
|
+
default:
|
|
220
|
+
if (this.isDigit(char)) {
|
|
221
|
+
this.scanNumber(char);
|
|
222
|
+
} else if (this.isAlpha(char)) {
|
|
223
|
+
this.scanIdentifier(char);
|
|
224
|
+
} else {
|
|
225
|
+
throw new SyntaxError(
|
|
226
|
+
`Unexpected character '${char}'`,
|
|
227
|
+
startPosition,
|
|
228
|
+
startLine,
|
|
229
|
+
startColumn,
|
|
230
|
+
this.input
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private isDigit(char: string): boolean {
|
|
237
|
+
return char >= '0' && char <= '9';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private isAlpha(char: string): boolean {
|
|
241
|
+
return (char >= 'a' && char <= 'z') ||
|
|
242
|
+
(char >= 'A' && char <= 'Z') ||
|
|
243
|
+
char === '_';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private isAlphaNumeric(char: string): boolean {
|
|
247
|
+
return this.isAlpha(char) || this.isDigit(char);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private scanVariable(): void {
|
|
251
|
+
let name = '';
|
|
252
|
+
while (!this.isAtEnd() && this.isAlphaNumeric(this.peek())) {
|
|
253
|
+
name += this.advance();
|
|
254
|
+
}
|
|
255
|
+
if (name === '') {
|
|
256
|
+
throw new SyntaxError(
|
|
257
|
+
'Expected variable name after $',
|
|
258
|
+
this.position,
|
|
259
|
+
this.line,
|
|
260
|
+
this.column,
|
|
261
|
+
this.input
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
this.addToken(TokenType.VARIABLE, name);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private scanContextVariable(): void {
|
|
268
|
+
let name = '';
|
|
269
|
+
while (!this.isAtEnd() && this.isAlphaNumeric(this.peek())) {
|
|
270
|
+
name += this.advance();
|
|
271
|
+
}
|
|
272
|
+
if (name === '') {
|
|
273
|
+
throw new SyntaxError(
|
|
274
|
+
'Expected variable name after @',
|
|
275
|
+
this.position,
|
|
276
|
+
this.line,
|
|
277
|
+
this.column,
|
|
278
|
+
this.input
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
this.addToken(TokenType.CONTEXT_VAR, name);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private scanString(quote: string): void {
|
|
285
|
+
const startPosition = this.position - 1;
|
|
286
|
+
let value = '';
|
|
287
|
+
|
|
288
|
+
while (!this.isAtEnd() && this.peek() !== quote) {
|
|
289
|
+
if (this.peek() === '\\') {
|
|
290
|
+
this.advance();
|
|
291
|
+
if (!this.isAtEnd()) {
|
|
292
|
+
const escaped = this.advance();
|
|
293
|
+
switch (escaped) {
|
|
294
|
+
case 'n': value += '\n'; break;
|
|
295
|
+
case 't': value += '\t'; break;
|
|
296
|
+
case 'r': value += '\r'; break;
|
|
297
|
+
case '\\': value += '\\'; break;
|
|
298
|
+
case '"': value += '"'; break;
|
|
299
|
+
case "'": value += "'"; break;
|
|
300
|
+
default: value += escaped;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
value += this.advance();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (this.isAtEnd()) {
|
|
309
|
+
throw new UnterminatedStringError(startPosition);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.advance(); // Consume closing quote
|
|
313
|
+
this.addToken(TokenType.STRING, value);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private scanNumber(firstChar: string): void {
|
|
317
|
+
const startPosition = this.position - 1;
|
|
318
|
+
let numStr = firstChar;
|
|
319
|
+
let isFloat = false;
|
|
320
|
+
|
|
321
|
+
// Integer part
|
|
322
|
+
while (!this.isAtEnd() && this.isDigit(this.peek())) {
|
|
323
|
+
numStr += this.advance();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Decimal part
|
|
327
|
+
if (this.peek() === '.' && this.isDigit(this.peekNext())) {
|
|
328
|
+
numStr += this.advance(); // consume '.'
|
|
329
|
+
while (!this.isAtEnd() && this.isDigit(this.peek())) {
|
|
330
|
+
numStr += this.advance();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Exponent part
|
|
335
|
+
if (this.peek() === 'e' || this.peek() === 'E') {
|
|
336
|
+
numStr += this.advance();
|
|
337
|
+
if (this.peek() === '+' || this.peek() === '-') {
|
|
338
|
+
numStr += this.advance();
|
|
339
|
+
}
|
|
340
|
+
if (!this.isDigit(this.peek())) {
|
|
341
|
+
throw new InvalidNumberError(numStr, startPosition);
|
|
342
|
+
}
|
|
343
|
+
while (!this.isAtEnd() && this.isDigit(this.peek())) {
|
|
344
|
+
numStr += this.advance();
|
|
345
|
+
}
|
|
346
|
+
isFloat = true; // Scientific notation is treated as float
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Check for float suffix 'f'
|
|
350
|
+
if (this.peek() === 'f' || this.peek() === 'F') {
|
|
351
|
+
this.advance();
|
|
352
|
+
isFloat = true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Check for decimal suffix 'd'
|
|
356
|
+
if (this.peek() === 'd' || this.peek() === 'D') {
|
|
357
|
+
this.advance();
|
|
358
|
+
isFloat = false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (isFloat) {
|
|
362
|
+
const value = parseFloat(numStr);
|
|
363
|
+
if (isNaN(value)) {
|
|
364
|
+
throw new InvalidNumberError(numStr, startPosition);
|
|
365
|
+
}
|
|
366
|
+
this.addToken(TokenType.NUMBER, value);
|
|
367
|
+
} else {
|
|
368
|
+
// Store as string to preserve precision for Decimal
|
|
369
|
+
this.addToken(TokenType.NUMBER, numStr);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private scanIdentifier(firstChar: string): void {
|
|
374
|
+
let name = firstChar;
|
|
375
|
+
|
|
376
|
+
while (!this.isAtEnd() && this.isAlphaNumeric(this.peek())) {
|
|
377
|
+
name += this.advance();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Check if it's a keyword
|
|
381
|
+
const keywordType = KEYWORDS[name] || KEYWORDS[name.toUpperCase()];
|
|
382
|
+
if (keywordType) {
|
|
383
|
+
if (keywordType === TokenType.BOOLEAN) {
|
|
384
|
+
this.addToken(TokenType.BOOLEAN, name.toLowerCase() === 'true');
|
|
385
|
+
} else if (keywordType === TokenType.NULL) {
|
|
386
|
+
this.addToken(TokenType.NULL, null);
|
|
387
|
+
} else {
|
|
388
|
+
this.addToken(keywordType, name.toUpperCase());
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
this.addToken(TokenType.IDENTIFIER, name);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|