Skip to content

Commit db16e80

Browse files
author
Sander Toonen
committed
Add jSDoc and throw custom errors
1 parent 8b6a152 commit db16e80

11 files changed

Lines changed: 953 additions & 50 deletions

index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ export type {
2626
OperatorFunction
2727
} from './src/types';
2828

29+
// Re-export custom error classes
30+
export {
31+
ExpressionError,
32+
ParseError,
33+
EvaluationError,
34+
ArgumentError,
35+
AccessError,
36+
VariableError,
37+
FunctionError
38+
} from './src/types';
39+
2940
export {
3041
Expression,
3142
Parser

package.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"author": "Matthew Crumley, Mark Sironi, Sander Toonen",
2323
"type": "module",
2424
"sideEffects": false,
25+
"engines": {
26+
"node": ">=18.0.0"
27+
},
2528
"main": "dist/index.mjs",
2629
"module": "dist/index.mjs",
2730
"types": "dist/index.d.ts",
@@ -81,5 +84,15 @@
8184
"vite": "^6.0.1",
8285
"vite-plugin-dts": "^4.3.0",
8386
"vitest": "^3.2.4"
84-
}
87+
},
88+
"bundlesize": [
89+
{
90+
"path": "./dist/bundle.min.js",
91+
"maxSize": "50kb"
92+
},
93+
{
94+
"path": "./dist/index.mjs",
95+
"maxSize": "80kb"
96+
}
97+
]
8598
}

src/evaluate.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { INUMBER, IOP1, IOP2, IOP3, IVAR, IVARNAME, IFUNCALL, IFUNDEF, IEXPR, IEXPREVAL, IMEMBER, IENDSTATEMENT, IARRAY, IUNDEFINED, ICASEMATCH, IWHENMATCH, ICASEELSE, ICASECOND, IWHENCOND, IOBJECT, IPROPERTY, IOBJECTEND } from './instruction';
22
import type { Instruction } from './instruction';
33
import type { Expression } from './expression';
4+
import { EvaluationError, VariableError, FunctionError, AccessError } from './types';
45

56
// cSpell:words INUMBER IVAR IVARNAME IFUNCALL IEXPR IEXPREVAL IMEMBER IENDSTATEMENT IARRAY
67
// cSpell:words IFUNDEF IUNDEFINED ICASEMATCH ICASECOND IWHENCOND IWHENMATCH ICASEELSE IPROPERTY
@@ -149,7 +150,11 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
149150
}
150151
} else if (type === IVAR) {
151152
if (/^__proto__|prototype|constructor$/.test(token.value as string)) {
152-
throw new Error('prototype access detected');
153+
throw new AccessError(
154+
'Prototype access detected',
155+
token.value as string,
156+
expr.toString()
157+
);
153158
}
154159
if (token.value in expr.functions) {
155160
nstack.push(expr.functions[token.value]);
@@ -184,7 +189,10 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
184189
}
185190
}
186191
if (!pushed) {
187-
throw new Error('undefined variable: ' + token.value);
192+
throw new VariableError(
193+
token.value as string,
194+
expr.toString()
195+
);
188196
}
189197
}
190198
} else if (type === IOP1) {
@@ -201,7 +209,11 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
201209
if (typeof f === 'function') {
202210
nstack.push(f.apply(undefined, args));
203211
} else {
204-
throw new Error(`${f}` + ' is not a function');
212+
throw new FunctionError(
213+
`${f} is not a function`,
214+
String(f),
215+
expr.toString()
216+
);
205217
}
206218
} else if (type === IFUNDEF) {
207219
// Create closure to keep references to arguments and expression

src/expression.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ export class Expression {
3030
public ternaryOps: Record<string, OperatorFunction>;
3131
public functions: Record<string, OperatorFunction>;
3232

33+
/**
34+
* Creates a new Expression instance. Usually created via Parser.parse().
35+
*
36+
* @param tokens - The compiled instruction tokens
37+
* @param parser - The parser instance that created this expression
38+
* @internal
39+
*/
3340
constructor(tokens: Instruction[], parser: ParserLike) {
3441
this.tokens = tokens;
3542
this.parser = parser;
@@ -39,11 +46,35 @@ export class Expression {
3946
this.functions = parser.functions;
4047
}
4148

49+
/**
50+
* Returns a simplified version of this expression.
51+
* Attempts to pre-compute parts of the expression that don't depend on variables.
52+
*
53+
* @param values - Optional object containing known variable values for simplification
54+
* @returns A new simplified Expression instance
55+
* @example
56+
* ```typescript
57+
* const expr = parser.parse('2 + 3 + x');
58+
* const simplified = expr.simplify(); // Results in '5 + x'
59+
* ```
60+
*/
4261
simplify(values?: Values): Expression {
4362
values = values || {};
4463
return new Expression(simplify(this.tokens, this.unaryOps, this.binaryOps, this.ternaryOps, values), this.parser);
4564
}
4665

66+
/**
67+
* Substitutes a variable with another expression.
68+
*
69+
* @param variable - The variable name to substitute
70+
* @param expr - The expression or expression string to substitute with
71+
* @returns A new Expression instance with the substitution applied
72+
* @example
73+
* ```typescript
74+
* const expr = parser.parse('x + y');
75+
* const substituted = expr.substitute('x', '2 * z'); // Results in '2 * z + y'
76+
* ```
77+
*/
4778
substitute(variable: string, expr: string | Expression): Expression {
4879
if (!(expr instanceof Expression)) {
4980
expr = this.parser.parse(String(expr));
@@ -52,32 +83,99 @@ export class Expression {
5283
return new Expression(substitute(this.tokens, variable, expr), this.parser);
5384
}
5485

86+
/**
87+
* Evaluates the expression with the given variable values.
88+
*
89+
* @param values - Object containing variable values
90+
* @returns The computed result of the expression
91+
* @throws {VariableError} When the expression references undefined variables
92+
* @throws {EvaluationError} When runtime evaluation fails
93+
* @example
94+
* ```typescript
95+
* const expr = parser.parse('2 + 3 * x');
96+
* const result = expr.evaluate({ x: 4 }); // Returns 14
97+
* ```
98+
*/
5599
evaluate(values?: Values): any {
56100
values = values || {};
57101
return evaluate(this.tokens, this, values);
58102
}
59103

104+
/**
105+
* Returns a string representation of the expression.
106+
*
107+
* @returns The expression as a human-readable string
108+
* @example
109+
* ```typescript
110+
* const expr = parser.parse('2 + 3 * x');
111+
* console.log(expr.toString()); // "2 + 3 * x"
112+
* ```
113+
*/
60114
toString(): string {
61115
return expressionToString(this.tokens, false);
62116
}
63117

118+
/**
119+
* Returns an array of all symbols (variables and functions) used in the expression.
120+
*
121+
* @param options - Options for symbol extraction
122+
* @param options.withMembers - Whether to include member access chains
123+
* @returns Array of symbol names
124+
* @example
125+
* ```typescript
126+
* const expr = parser.parse('x + sin(y) + obj.prop');
127+
* const symbols = expr.symbols(); // ['x', 'sin', 'y', 'obj', 'prop']
128+
* const symbolsWithMembers = expr.symbols({ withMembers: true }); // ['x', 'sin', 'y', 'obj.prop']
129+
* ```
130+
*/
64131
symbols(options?: SymbolOptions): string[] {
65132
options = options || {};
66133
const vars: string[] = [];
67134
getSymbols(this.tokens, vars, options);
68135
return vars;
69136
}
70137

138+
/**
139+
* Returns an array of variables used in the expression (excludes function names).
140+
*
141+
* @param options - Options for variable extraction
142+
* @param options.withMembers - Whether to include member access chains
143+
* @returns Array of variable names
144+
* @example
145+
* ```typescript
146+
* const expr = parser.parse('x + sin(y) + obj.prop');
147+
* const variables = expr.variables(); // ['x', 'y', 'obj', 'prop'] (sin is excluded as it's a function)
148+
* ```
149+
*/
71150
variables(options?: SymbolOptions): string[] {
72151
options = options || {};
73152
const vars: string[] = [];
74153
getSymbols(this.tokens, vars, options);
75-
const functions = this.functions;
154+
const { functions } = this;
76155
return vars.filter(function (name) {
77156
return !(name in functions);
78157
});
79158
}
80159

160+
/**
161+
* Compiles the expression into a JavaScript function.
162+
* The resulting function can be called with arguments corresponding to variables in the expression.
163+
*
164+
* @param param - The parameter name for the generated function
165+
* @param variables - Optional pre-computed variable values for optimization
166+
* @returns A JavaScript function that evaluates the expression
167+
* @example
168+
* ```typescript
169+
* const expr = parser.parse('2 + 3 * x');
170+
* const fn = expr.toJSFunction('x');
171+
* const result = fn(4); // Returns 14
172+
*
173+
* // Multiple parameters
174+
* const expr2 = parser.parse('x + y * z');
175+
* const fn2 = expr2.toJSFunction('vars');
176+
* const result2 = fn2({ x: 1, y: 2, z: 3 }); // Returns 7
177+
* ```
178+
*/
81179
toJSFunction(param: string, variables?: Values): (...args: any[]) => any {
82180
const expr = this;
83181
const f = new Function(param, 'with(this.functions) with (this.ternaryOps) with (this.binaryOps) with (this.unaryOps) { return ' + expressionToString(this.simplify(variables).tokens, true) + '; }');

src/parser-state.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { TOP, TNUMBER, TSTRING, TPAREN, TBRACKET, TCOMMA, TNAME, TSEMICOLON, TEO
77
import { Instruction, INUMBER, IVAR, IVARNAME, IFUNCALL, IFUNDEF, IEXPR, IMEMBER, IENDSTATEMENT, IARRAY, IUNDEFINED, ternaryInstruction, binaryInstruction, unaryInstruction, IWHENMATCH, ICASEMATCH, ICASEELSE, ICASECOND, IWHENCOND, IPROPERTY, IOBJECT, IOBJECTEND, InstructionType } from './instruction';
88
import contains from './contains';
99
import { TokenStream } from './token-stream';
10+
import { ParseError, AccessError } from './types';
1011

1112
// Parser interface (will be more complete when we convert parser.js)
1213
interface ParserLike {
@@ -84,7 +85,12 @@ export class ParserState {
8485
expect(type: TokenType, value?: TokenValueMatcher): void {
8586
if (!this.accept(type, value)) {
8687
const coords = this.tokens.getCoordinates();
87-
throw new Error('parse error [' + coords.line + ':' + coords.column + ']: Expected ' + (value || type));
88+
throw new ParseError(
89+
`Expected ${value || type}`,
90+
{ line: coords.line, column: coords.column },
91+
this.nextToken?.value?.toString(),
92+
this.tokens.expression
93+
);
8894
}
8995
}
9096

@@ -120,7 +126,13 @@ export class ParserState {
120126
} else if (this.accept(TKEYWORD)) {
121127
this.parseKeywordExpression(instr);
122128
} else {
123-
throw new Error('unexpected ' + this.nextToken);
129+
const coords = this.tokens.getCoordinates();
130+
throw new ParseError(
131+
`Unexpected token: ${this.nextToken}`,
132+
{ line: coords.line, column: coords.column },
133+
this.nextToken?.value?.toString(),
134+
this.tokens.expression
135+
);
124136
}
125137
}
126138

@@ -177,7 +189,13 @@ export class ParserState {
177189
const lastInstrIndex = instr.length - 1;
178190
if (varName.type === IFUNCALL) {
179191
if (!this.tokens.isOperatorEnabled('()=')) {
180-
throw new Error('function definition is not permitted');
192+
const coords = this.tokens.getCoordinates();
193+
throw new ParseError(
194+
'function definition is not permitted',
195+
{ line: coords.line, column: coords.column },
196+
undefined,
197+
this.tokens.expression
198+
);
181199
}
182200
for (let i = 0, len = (varName.value as number) + 1; i < len; i++) {
183201
const index = lastInstrIndex - i;
@@ -367,14 +385,22 @@ export class ParserState {
367385

368386
if (op.value === '.') {
369387
if (!this.allowMemberAccess) {
370-
throw new Error('unexpected ".", member access is not permitted');
388+
throw new AccessError(
389+
'member access is not permitted',
390+
undefined,
391+
this.tokens.expression
392+
);
371393
}
372394

373395
this.expect(TNAME);
374396
instr.push(new Instruction(IMEMBER, this.current!.value));
375397
} else if (op.value === '[') {
376398
if (!this.tokens.isOperatorEnabled('[')) {
377-
throw new Error('unexpected "[]", arrays are disabled');
399+
throw new AccessError(
400+
'Array access is disabled',
401+
undefined,
402+
this.tokens.expression
403+
);
378404
}
379405

380406
this.parseExpression(instr);

0 commit comments

Comments
 (0)