@the-trybe/formula-engine 1.1.0 → 1.2.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/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +38 -0
- package/PRD_FORMULA_ENGINE.md +55 -1
- package/dist/dependency-extractor.js +6 -1
- package/dist/evaluator.js +8 -1
- package/dist/formula-engine.d.ts +6 -2
- package/dist/formula-engine.js +20 -5
- package/dist/functions.js +146 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lexer.js +7 -1
- package/dist/parser.d.ts +1 -0
- package/dist/parser.js +31 -2
- package/dist/types.d.ts +19 -1
- package/dist/types.js +3 -1
- package/package.json +3 -2
- package/src/dependency-extractor.ts +6 -0
- package/src/dependency-graph.test.ts +24 -0
- package/src/evaluator.ts +8 -0
- package/src/formula-engine.test.ts +532 -0
- package/src/functions.ts +151 -0
- package/src/index.ts +2 -0
- package/src/lexer.test.ts +61 -0
- package/src/lexer.ts +6 -0
- package/src/parser.test.ts +109 -0
- package/src/parser.ts +40 -1
- package/src/types.ts +13 -0
- package/.claude/settings.local.json +0 -6
package/src/lexer.test.ts
CHANGED
|
@@ -285,4 +285,65 @@ describe('Lexer', () => {
|
|
|
285
285
|
expect(() => lexer.tokenize()).toThrow(SyntaxError);
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
|
+
|
|
289
|
+
describe('Braces', () => {
|
|
290
|
+
it('should tokenize left brace', () => {
|
|
291
|
+
const lexer = new Lexer('{');
|
|
292
|
+
const tokens = lexer.tokenize();
|
|
293
|
+
|
|
294
|
+
expect(tokens[0].type).toBe(TokenType.LBRACE);
|
|
295
|
+
expect(tokens[0].value).toBe('{');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should tokenize right brace', () => {
|
|
299
|
+
const lexer = new Lexer('}');
|
|
300
|
+
const tokens = lexer.tokenize();
|
|
301
|
+
|
|
302
|
+
expect(tokens[0].type).toBe(TokenType.RBRACE);
|
|
303
|
+
expect(tokens[0].value).toBe('}');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should tokenize object literal tokens', () => {
|
|
307
|
+
const lexer = new Lexer('{ a: 1, b: 2 }');
|
|
308
|
+
const tokens = lexer.tokenize();
|
|
309
|
+
|
|
310
|
+
expect(tokens[0].type).toBe(TokenType.LBRACE);
|
|
311
|
+
expect(tokens[1].type).toBe(TokenType.IDENTIFIER);
|
|
312
|
+
expect(tokens[1].value).toBe('a');
|
|
313
|
+
expect(tokens[2].type).toBe(TokenType.COLON);
|
|
314
|
+
expect(tokens[3].type).toBe(TokenType.NUMBER);
|
|
315
|
+
expect(tokens[3].value).toBe('1');
|
|
316
|
+
expect(tokens[4].type).toBe(TokenType.COMMA);
|
|
317
|
+
expect(tokens[5].type).toBe(TokenType.IDENTIFIER);
|
|
318
|
+
expect(tokens[5].value).toBe('b');
|
|
319
|
+
expect(tokens[6].type).toBe(TokenType.COLON);
|
|
320
|
+
expect(tokens[7].type).toBe(TokenType.NUMBER);
|
|
321
|
+
expect(tokens[7].value).toBe('2');
|
|
322
|
+
expect(tokens[8].type).toBe(TokenType.RBRACE);
|
|
323
|
+
expect(tokens[9].type).toBe(TokenType.EOF);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should tokenize nested braces', () => {
|
|
327
|
+
const lexer = new Lexer('{ a: { b: 1 } }');
|
|
328
|
+
const tokens = lexer.tokenize();
|
|
329
|
+
|
|
330
|
+
expect(tokens[0].type).toBe(TokenType.LBRACE);
|
|
331
|
+
expect(tokens[3].type).toBe(TokenType.LBRACE);
|
|
332
|
+
expect(tokens[7].type).toBe(TokenType.RBRACE);
|
|
333
|
+
expect(tokens[8].type).toBe(TokenType.RBRACE);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should tokenize braces with variable values', () => {
|
|
337
|
+
const lexer = new Lexer('{ type: @client.type }');
|
|
338
|
+
const tokens = lexer.tokenize();
|
|
339
|
+
|
|
340
|
+
expect(tokens[0].type).toBe(TokenType.LBRACE);
|
|
341
|
+
expect(tokens[1].type).toBe(TokenType.IDENTIFIER);
|
|
342
|
+
expect(tokens[2].type).toBe(TokenType.COLON);
|
|
343
|
+
expect(tokens[3].type).toBe(TokenType.CONTEXT_VAR);
|
|
344
|
+
expect(tokens[4].type).toBe(TokenType.DOT);
|
|
345
|
+
expect(tokens[5].type).toBe(TokenType.IDENTIFIER);
|
|
346
|
+
expect(tokens[6].type).toBe(TokenType.RBRACE);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
288
349
|
});
|
package/src/lexer.ts
CHANGED
|
@@ -110,6 +110,12 @@ export class Lexer {
|
|
|
110
110
|
case ']':
|
|
111
111
|
this.addToken(TokenType.RBRACKET, ']');
|
|
112
112
|
break;
|
|
113
|
+
case '{':
|
|
114
|
+
this.addToken(TokenType.LBRACE, '{');
|
|
115
|
+
break;
|
|
116
|
+
case '}':
|
|
117
|
+
this.addToken(TokenType.RBRACE, '}');
|
|
118
|
+
break;
|
|
113
119
|
case ',':
|
|
114
120
|
this.addToken(TokenType.COMMA, ',');
|
|
115
121
|
break;
|
package/src/parser.test.ts
CHANGED
|
@@ -346,4 +346,113 @@ describe('Parser', () => {
|
|
|
346
346
|
expect(() => parser.parse('$a ? $b')).toThrow(UnexpectedTokenError);
|
|
347
347
|
});
|
|
348
348
|
});
|
|
349
|
+
|
|
350
|
+
describe('Object Literals', () => {
|
|
351
|
+
it('should parse empty object literal', () => {
|
|
352
|
+
const ast = parser.parse('{}');
|
|
353
|
+
|
|
354
|
+
expect(ast.type).toBe('ObjectLiteral');
|
|
355
|
+
expect((ast as any).properties).toHaveLength(0);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should parse single-property object literal', () => {
|
|
359
|
+
const ast = parser.parse('{ name: "test" }');
|
|
360
|
+
|
|
361
|
+
expect(ast.type).toBe('ObjectLiteral');
|
|
362
|
+
expect((ast as any).properties).toHaveLength(1);
|
|
363
|
+
expect((ast as any).properties[0].key).toBe('name');
|
|
364
|
+
expect((ast as any).properties[0].value.type).toBe('StringLiteral');
|
|
365
|
+
expect((ast as any).properties[0].value.value).toBe('test');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('should parse multi-property object literal', () => {
|
|
369
|
+
const ast = parser.parse('{ a: 1, b: 2, c: 3 }');
|
|
370
|
+
|
|
371
|
+
expect(ast.type).toBe('ObjectLiteral');
|
|
372
|
+
expect((ast as any).properties).toHaveLength(3);
|
|
373
|
+
expect((ast as any).properties[0].key).toBe('a');
|
|
374
|
+
expect((ast as any).properties[1].key).toBe('b');
|
|
375
|
+
expect((ast as any).properties[2].key).toBe('c');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should parse object literal with expression values', () => {
|
|
379
|
+
const ast = parser.parse('{ total: $a + $b }');
|
|
380
|
+
|
|
381
|
+
expect(ast.type).toBe('ObjectLiteral');
|
|
382
|
+
expect((ast as any).properties[0].key).toBe('total');
|
|
383
|
+
expect((ast as any).properties[0].value.type).toBe('BinaryOperation');
|
|
384
|
+
expect((ast as any).properties[0].value.operator).toBe('+');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should parse object literal with context variable values', () => {
|
|
388
|
+
const ast = parser.parse('{ zone: @client.zone }');
|
|
389
|
+
|
|
390
|
+
expect(ast.type).toBe('ObjectLiteral');
|
|
391
|
+
expect((ast as any).properties[0].key).toBe('zone');
|
|
392
|
+
expect((ast as any).properties[0].value.type).toBe('MemberAccess');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should parse object literal with variable values', () => {
|
|
396
|
+
const ast = parser.parse('{ price: $unitPrice, qty: $quantity }');
|
|
397
|
+
|
|
398
|
+
expect(ast.type).toBe('ObjectLiteral');
|
|
399
|
+
expect((ast as any).properties).toHaveLength(2);
|
|
400
|
+
expect((ast as any).properties[0].value.type).toBe('VariableReference');
|
|
401
|
+
expect((ast as any).properties[0].value.name).toBe('unitPrice');
|
|
402
|
+
expect((ast as any).properties[1].value.type).toBe('VariableReference');
|
|
403
|
+
expect((ast as any).properties[1].value.name).toBe('quantity');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should parse nested object literals', () => {
|
|
407
|
+
const ast = parser.parse('{ inner: { x: 1 } }');
|
|
408
|
+
|
|
409
|
+
expect(ast.type).toBe('ObjectLiteral');
|
|
410
|
+
expect((ast as any).properties[0].key).toBe('inner');
|
|
411
|
+
expect((ast as any).properties[0].value.type).toBe('ObjectLiteral');
|
|
412
|
+
expect((ast as any).properties[0].value.properties[0].key).toBe('x');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should parse object literal as function argument', () => {
|
|
416
|
+
const ast = parser.parse('LOOKUP($t, { a: 1 }, "r")');
|
|
417
|
+
|
|
418
|
+
expect(ast.type).toBe('FunctionCall');
|
|
419
|
+
expect((ast as any).name).toBe('LOOKUP');
|
|
420
|
+
expect((ast as any).arguments).toHaveLength(3);
|
|
421
|
+
expect((ast as any).arguments[1].type).toBe('ObjectLiteral');
|
|
422
|
+
expect((ast as any).arguments[1].properties[0].key).toBe('a');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('should parse object literal with boolean and null values', () => {
|
|
426
|
+
const ast = parser.parse('{ active: true, deleted: false, data: null }');
|
|
427
|
+
|
|
428
|
+
expect(ast.type).toBe('ObjectLiteral');
|
|
429
|
+
expect((ast as any).properties).toHaveLength(3);
|
|
430
|
+
expect((ast as any).properties[0].value.type).toBe('BooleanLiteral');
|
|
431
|
+
expect((ast as any).properties[1].value.type).toBe('BooleanLiteral');
|
|
432
|
+
expect((ast as any).properties[2].value.type).toBe('NullLiteral');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should parse object literal with array value', () => {
|
|
436
|
+
const ast = parser.parse('{ items: [1, 2, 3] }');
|
|
437
|
+
|
|
438
|
+
expect(ast.type).toBe('ObjectLiteral');
|
|
439
|
+
expect((ast as any).properties[0].value.type).toBe('ArrayLiteral');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should throw on missing colon in object literal', () => {
|
|
443
|
+
expect(() => parser.parse('{ a 1 }')).toThrow(UnexpectedTokenError);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('should throw on non-identifier key', () => {
|
|
447
|
+
expect(() => parser.parse('{ 123: 1 }')).toThrow(UnexpectedTokenError);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('should throw on string key', () => {
|
|
451
|
+
expect(() => parser.parse('{ "key": 1 }')).toThrow(UnexpectedTokenError);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should throw on unclosed brace', () => {
|
|
455
|
+
expect(() => parser.parse('{ a: 1')).toThrow(UnexpectedTokenError);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
349
458
|
});
|
package/src/parser.ts
CHANGED
|
@@ -75,13 +75,15 @@ export class Parser {
|
|
|
75
75
|
return this.parseGroupedExpression();
|
|
76
76
|
case TokenType.LBRACKET:
|
|
77
77
|
return this.parseArrayLiteral();
|
|
78
|
+
case TokenType.LBRACE:
|
|
79
|
+
return this.parseObjectLiteral();
|
|
78
80
|
case TokenType.MINUS:
|
|
79
81
|
case TokenType.NOT:
|
|
80
82
|
return this.parseUnaryExpression();
|
|
81
83
|
default:
|
|
82
84
|
throw new UnexpectedTokenError(
|
|
83
85
|
String(token.value),
|
|
84
|
-
['number', 'string', 'boolean', 'null', 'variable', 'identifier', '(', '[', '-', '!'],
|
|
86
|
+
['number', 'string', 'boolean', 'null', 'variable', 'identifier', '(', '[', '{', '-', '!'],
|
|
85
87
|
token.position
|
|
86
88
|
);
|
|
87
89
|
}
|
|
@@ -266,6 +268,43 @@ export class Parser {
|
|
|
266
268
|
};
|
|
267
269
|
}
|
|
268
270
|
|
|
271
|
+
private parseObjectLiteral(): ASTNode {
|
|
272
|
+
this.advance(); // consume '{'
|
|
273
|
+
const properties: { key: string; value: ASTNode }[] = [];
|
|
274
|
+
|
|
275
|
+
if (this.peek().type !== TokenType.RBRACE) {
|
|
276
|
+
do {
|
|
277
|
+
if (this.peek().type === TokenType.COMMA) {
|
|
278
|
+
this.advance();
|
|
279
|
+
}
|
|
280
|
+
// Key must be an identifier
|
|
281
|
+
const keyToken = this.peek();
|
|
282
|
+
if (keyToken.type !== TokenType.IDENTIFIER) {
|
|
283
|
+
throw new UnexpectedTokenError(
|
|
284
|
+
String(keyToken.value),
|
|
285
|
+
['identifier (property name)'],
|
|
286
|
+
keyToken.position
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const key = String(this.advance().value);
|
|
290
|
+
|
|
291
|
+
// Expect colon
|
|
292
|
+
this.expect(TokenType.COLON, ':');
|
|
293
|
+
|
|
294
|
+
// Value is any expression
|
|
295
|
+
const value = this.parseExpression(PRECEDENCE.LOWEST);
|
|
296
|
+
properties.push({ key, value });
|
|
297
|
+
} while (this.peek().type === TokenType.COMMA);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
this.expect(TokenType.RBRACE, '}');
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
type: 'ObjectLiteral',
|
|
304
|
+
properties,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
269
308
|
private parseUnaryExpression(): ASTNode {
|
|
270
309
|
const token = this.advance();
|
|
271
310
|
const operator = this.getOperatorSymbol(token.type);
|
package/src/types.ts
CHANGED
|
@@ -143,6 +143,16 @@ export interface ArrayLiteral {
|
|
|
143
143
|
elements: ASTNode[];
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
export interface ObjectLiteralProperty {
|
|
147
|
+
key: string;
|
|
148
|
+
value: ASTNode;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ObjectLiteral {
|
|
152
|
+
type: 'ObjectLiteral';
|
|
153
|
+
properties: ObjectLiteralProperty[];
|
|
154
|
+
}
|
|
155
|
+
|
|
146
156
|
export interface VariableReference {
|
|
147
157
|
type: 'VariableReference';
|
|
148
158
|
prefix: '$' | '@';
|
|
@@ -194,6 +204,7 @@ export type ASTNode =
|
|
|
194
204
|
| BooleanLiteral
|
|
195
205
|
| NullLiteral
|
|
196
206
|
| ArrayLiteral
|
|
207
|
+
| ObjectLiteral
|
|
197
208
|
| VariableReference
|
|
198
209
|
| BinaryOperation
|
|
199
210
|
| UnaryOperation
|
|
@@ -342,6 +353,8 @@ export enum TokenType {
|
|
|
342
353
|
RPAREN = 'RPAREN',
|
|
343
354
|
LBRACKET = 'LBRACKET',
|
|
344
355
|
RBRACKET = 'RBRACKET',
|
|
356
|
+
LBRACE = 'LBRACE',
|
|
357
|
+
RBRACE = 'RBRACE',
|
|
345
358
|
COMMA = 'COMMA',
|
|
346
359
|
DOT = 'DOT',
|
|
347
360
|
QUESTION = 'QUESTION',
|