@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.
Files changed (43) hide show
  1. package/.claude/settings.local.json +6 -0
  2. package/PRD_FORMULA_ENGINE.md +1863 -0
  3. package/README.md +382 -0
  4. package/dist/decimal-utils.d.ts +180 -0
  5. package/dist/decimal-utils.js +355 -0
  6. package/dist/dependency-extractor.d.ts +20 -0
  7. package/dist/dependency-extractor.js +103 -0
  8. package/dist/dependency-graph.d.ts +60 -0
  9. package/dist/dependency-graph.js +252 -0
  10. package/dist/errors.d.ts +161 -0
  11. package/dist/errors.js +260 -0
  12. package/dist/evaluator.d.ts +51 -0
  13. package/dist/evaluator.js +494 -0
  14. package/dist/formula-engine.d.ts +79 -0
  15. package/dist/formula-engine.js +355 -0
  16. package/dist/functions.d.ts +3 -0
  17. package/dist/functions.js +720 -0
  18. package/dist/index.d.ts +10 -0
  19. package/dist/index.js +61 -0
  20. package/dist/lexer.d.ts +25 -0
  21. package/dist/lexer.js +357 -0
  22. package/dist/parser.d.ts +32 -0
  23. package/dist/parser.js +372 -0
  24. package/dist/types.d.ts +228 -0
  25. package/dist/types.js +62 -0
  26. package/jest.config.js +23 -0
  27. package/package.json +35 -0
  28. package/src/decimal-utils.ts +408 -0
  29. package/src/dependency-extractor.ts +117 -0
  30. package/src/dependency-graph.test.ts +238 -0
  31. package/src/dependency-graph.ts +288 -0
  32. package/src/errors.ts +296 -0
  33. package/src/evaluator.ts +604 -0
  34. package/src/formula-engine.test.ts +660 -0
  35. package/src/formula-engine.ts +430 -0
  36. package/src/functions.ts +770 -0
  37. package/src/index.ts +103 -0
  38. package/src/lexer.test.ts +288 -0
  39. package/src/lexer.ts +394 -0
  40. package/src/parser.test.ts +349 -0
  41. package/src/parser.ts +449 -0
  42. package/src/types.ts +347 -0
  43. 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
+ }