formula-evaluator 1.0.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/README.md CHANGED
@@ -86,18 +86,91 @@ const ast = evaluator.parse(tokens);
86
86
  - **Strings**: `"hello world"`
87
87
  - **Booleans**: `true`, `false`
88
88
 
89
- ## Adding Custom Functions
89
+ ### `registerFunction(name, fn)`
90
90
 
91
- You can extend the evaluator by adding functions directly to the `FUNCTIONS` object:
91
+ Register a custom function that can be called in formulas. Returns the evaluator instance so calls can be chained.
92
92
 
93
93
  ```js
94
94
  const evaluator = new FormulaEvaluator();
95
95
 
96
- evaluator.FUNCTIONS.double = (x) => x * 2;
97
- evaluator.FUNCTIONS.min = (...args) => Math.min(...args);
96
+ evaluator
97
+ .registerFunction('double', (x) => x * 2)
98
+ .registerFunction('clamp', (val, min, max) => Math.min(Math.max(val, min), max));
98
99
 
99
- evaluator.evaluate('double(5)'); // 10
100
- evaluator.evaluate('min(3, 1, 2)'); // 1
100
+ evaluator.evaluate('double(5)'); // 10
101
+ evaluator.evaluate('clamp(15, 0, 10)'); // 10
102
+ ```
103
+
104
+ Custom functions receive their arguments already evaluated, so they work naturally with variables, operators, and nested calls:
105
+
106
+ ```js
107
+ evaluator.registerFunction('double', (x) => x * 2);
108
+
109
+ evaluator.evaluate('double(n)', { n: 4 }); // 8
110
+ evaluator.evaluate('double(3) + 1'); // 7
111
+ evaluator.evaluate('double(sum(1, 2))'); // 6
112
+ ```
113
+
114
+ You can also override built-in functions:
115
+
116
+ ```js
117
+ evaluator.registerFunction('sum', (...args) => args.reduce((a, b) => a + b, 0));
118
+ ```
119
+
120
+ ### `listFunctions()`
121
+
122
+ Returns the names of all registered public functions (excludes internal operator mappings):
123
+
124
+ ```js
125
+ evaluator.listFunctions(); // ['upper', 'join', 'sum', 'avg', 'if']
126
+
127
+ evaluator.registerFunction('double', (x) => x * 2);
128
+ evaluator.listFunctions(); // ['upper', 'join', 'sum', 'avg', 'if', 'double']
129
+ ```
130
+
131
+ ## Function Registry
132
+
133
+ For advanced use cases you can work with the function registry directly. The `createFunctionRegistry` factory and the `builtinFunctions` map are available as named exports:
134
+
135
+ ```js
136
+ import { createFunctionRegistry, builtinFunctions } from 'formula-evaluator';
137
+ ```
138
+
139
+ ### `createFunctionRegistry(initialFunctions?)`
140
+
141
+ Creates a standalone registry pre-loaded with the built-in functions. Useful when you want to prepare a set of functions before constructing an evaluator, or share a registry definition across modules.
142
+
143
+ ```js
144
+ import { createFunctionRegistry } from 'formula-evaluator';
145
+
146
+ const registry = createFunctionRegistry({
147
+ double: (x) => x * 2,
148
+ });
149
+
150
+ registry.register('triple', (x) => x * 3);
151
+ registry.has('sum'); // true (built-in)
152
+ registry.has('double'); // true (initial)
153
+ registry.has('triple'); // true (registered)
154
+ registry.get('double')(5); // 10
155
+ registry.list(); // ['upper', 'join', 'sum', 'avg', 'if', 'double', 'triple']
156
+ registry.unregister('triple');
157
+ registry.has('triple'); // false
158
+ ```
159
+
160
+ Built-in functions are protected and cannot be unregistered:
161
+
162
+ ```js
163
+ registry.unregister('sum'); // throws Error: Cannot unregister built-in function "sum"
164
+ ```
165
+
166
+ ### `builtinFunctions`
167
+
168
+ A frozen object containing the default function implementations. Useful for inspection or when building a custom registry from scratch:
169
+
170
+ ```js
171
+ import { builtinFunctions } from 'formula-evaluator';
172
+
173
+ Object.keys(builtinFunctions); // ['upper', 'join', 'sum', 'avg', 'if', '__add', '__sub', '__eq']
101
174
  ```
102
175
 
103
176
  ## Development
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "formula-evaluator",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "A lightweight formula evaluator with dependency tracking and static analysis.",
5
5
  "main": "src/index.js",
6
6
  "exports": {
7
- ".": "./src/index.js"
7
+ ".": "./src/index.js",
8
+ "./functions": "./src/functions.js"
8
9
  },
9
10
  "type": "module",
10
11
  "files": [
@@ -0,0 +1,256 @@
1
+ /**
2
+ * @module functions
3
+ * Built-in function library and registry for the formula evaluator.
4
+ */
5
+
6
+ /**
7
+ * A function that can be called during formula evaluation.
8
+ * @callback FormulaFunction
9
+ * @param {...*} args - Evaluated arguments from the formula
10
+ * @returns {*} The computed result
11
+ */
12
+
13
+ /**
14
+ * A function definition containing the implementation and a human-readable description.
15
+ * @typedef {Object} FunctionDef
16
+ * @property {FormulaFunction} fn - The function implementation
17
+ * @property {string} description - A human-readable description of what the function does
18
+ */
19
+
20
+ /**
21
+ * Built-in functions included in every new evaluator instance.
22
+ * Each entry contains a `fn` implementation and a human-readable `description`.
23
+ * @type {Readonly<Record<string, FunctionDef>>}
24
+ */
25
+ export const builtinFunctions = Object.freeze({
26
+ upper: {
27
+ fn: (str) => String(str).toUpperCase(),
28
+ description: 'Converts a value to an uppercase string',
29
+ },
30
+
31
+ join: {
32
+ fn: (sep, ...args) => args.join(sep),
33
+ description: 'Joins arguments with a separator',
34
+ },
35
+
36
+ sum: {
37
+ fn: (...args) => args.reduce((a, b) => Number(a) + Number(b), 0),
38
+ description: 'Sums all arguments numerically',
39
+ },
40
+
41
+ avg: {
42
+ fn: (...args) => args.reduce((a, b) => a + b, 0) / args.length,
43
+ description: 'Returns the arithmetic mean of all arguments',
44
+ },
45
+
46
+ if: {
47
+ fn: (cond, a, b) => (cond ? a : b),
48
+ description: 'Returns the second argument if the condition is truthy, otherwise the third',
49
+ },
50
+
51
+ coalesce: {
52
+ fn: (...args) => args.find(a => a != null),
53
+ description: 'Returns the first non-null/non-undefined value',
54
+ },
55
+
56
+ isblank: {
57
+ fn: (val) => val === '' || val == null,
58
+ description: 'Returns true if a value is an empty string or null/undefined',
59
+ },
60
+
61
+ and: {
62
+ fn: (...args) => args.every(Boolean),
63
+ description: 'Returns true if all arguments are truthy',
64
+ },
65
+
66
+ or: {
67
+ fn: (...args) => args.some(Boolean),
68
+ description: 'Returns true if any argument is truthy',
69
+ },
70
+
71
+ iferr: {
72
+ fn: (val) => val,
73
+ description: 'Returns the first argument, or the second if the first throws an error',
74
+ },
75
+
76
+ round: {
77
+ fn: (n, d) => Number(Math.round(n + 'e' + d) + 'e-' + d),
78
+ description: 'Rounds a number to a specific decimal precision',
79
+ },
80
+
81
+ clamp: {
82
+ fn: (val, min, max) => Math.min(Math.max(val, min), max),
83
+ description: 'Restricts a number to a given range',
84
+ },
85
+
86
+ abs: {
87
+ fn: (n) => Math.abs(n),
88
+ description: 'Returns the absolute value of a number',
89
+ },
90
+
91
+ concat: {
92
+ fn: (...args) => args.join(''),
93
+ description: 'Concatenates all arguments without a separator',
94
+ },
95
+
96
+ /** @private */
97
+ __add: {
98
+ fn: (a, b) => a + b,
99
+ description: 'Addition operator',
100
+ },
101
+
102
+ /** @private */
103
+ __sub: {
104
+ fn: (a, b) => a - b,
105
+ description: 'Subtraction operator',
106
+ },
107
+
108
+ /** @private */
109
+ __eq: {
110
+ fn: (a, b) => a === b,
111
+ description: 'Equality operator',
112
+ },
113
+
114
+ /** @private */
115
+ __gt: {
116
+ fn: (a, b) => a > b,
117
+ description: 'Greater-than operator',
118
+ },
119
+
120
+ /** @private */
121
+ __gte: {
122
+ fn: (a, b) => a >= b,
123
+ description: 'Greater-than-or-equal operator',
124
+ },
125
+
126
+ /** @private */
127
+ __lt: {
128
+ fn: (a, b) => a < b,
129
+ description: 'Less-than operator',
130
+ },
131
+
132
+ /** @private */
133
+ __lte: {
134
+ fn: (a, b) => a <= b,
135
+ description: 'Less-than-or-equal operator',
136
+ },
137
+ });
138
+
139
+ /**
140
+ * @typedef {Object} FunctionRegistry
141
+ * @property {(name: string, fn: FormulaFunction, description?: string) => void} register - Register a new function
142
+ * @property {(name: string) => FormulaFunction|undefined} get - Retrieve a function by name
143
+ * @property {(name: string) => boolean} has - Check whether a function is registered
144
+ * @property {(name: string) => boolean} unregister - Remove a custom (non-built-in) function
145
+ * @property {() => string[]} list - List public function names (excludes internal __ operators)
146
+ * @property {() => Array<{name: string, description: string}>} describe - List public function names and descriptions
147
+ * @property {() => Record<string, FunctionDef>} getAll - Get a snapshot of all registered function definitions
148
+ */
149
+
150
+ /**
151
+ * Creates a function registry pre-loaded with the built-in functions.
152
+ *
153
+ * Use this to build a standalone registry, or let {@link FormulaEvaluator}
154
+ * create one automatically via its constructor.
155
+ *
156
+ * @param {Record<string, FormulaFunction>} [initialFunctions={}] - Extra functions to register on creation
157
+ * @returns {FunctionRegistry} A new registry instance
158
+ *
159
+ * @example
160
+ * import { createFunctionRegistry } from 'formula-evaluator/functions';
161
+ *
162
+ * const registry = createFunctionRegistry({
163
+ * double: (x) => x * 2,
164
+ * });
165
+ * registry.register('triple', (x) => x * 3, 'Triples a number');
166
+ * registry.get('double')(5); // 10
167
+ * registry.list(); // ['upper', 'join', 'sum', 'avg', 'if', 'double', 'triple']
168
+ * registry.describe(); // [{ name: 'upper', description: 'Converts a value to an uppercase string' }, ...]
169
+ */
170
+ export function createFunctionRegistry(initialFunctions = {}) {
171
+ /** @type {Record<string, FunctionDef>} */
172
+ const functions = { ...builtinFunctions };
173
+
174
+ for (const [name, val] of Object.entries(initialFunctions)) {
175
+ functions[name] = typeof val === 'function'
176
+ ? { fn: val, description: '' }
177
+ : { ...val };
178
+ }
179
+
180
+ return {
181
+ /**
182
+ * Registers a function under the given name.
183
+ * @param {string} name - Function name (used in formula strings)
184
+ * @param {FormulaFunction} fn - The implementation
185
+ * @param {string} [description=''] - A human-readable description of the function
186
+ * @throws {Error} If name is not a non-empty string or fn is not a function
187
+ */
188
+ register(name, fn, description = '') {
189
+ if (typeof name !== 'string' || !name) {
190
+ throw new Error('Function name must be a non-empty string');
191
+ }
192
+ if (typeof fn !== 'function') {
193
+ throw new Error('Function implementation must be a function');
194
+ }
195
+ functions[name] = { fn, description };
196
+ },
197
+
198
+ /**
199
+ * Retrieves a function implementation by name.
200
+ * @param {string} name
201
+ * @returns {FormulaFunction|undefined}
202
+ */
203
+ get(name) {
204
+ return functions[name]?.fn;
205
+ },
206
+
207
+ /**
208
+ * Checks whether a function is registered.
209
+ * @param {string} name
210
+ * @returns {boolean}
211
+ */
212
+ has(name) {
213
+ return name in functions;
214
+ },
215
+
216
+ /**
217
+ * Removes a custom function. Built-in functions cannot be unregistered.
218
+ * @param {string} name
219
+ * @returns {boolean} `true` if the function was removed
220
+ * @throws {Error} If attempting to unregister a built-in function
221
+ */
222
+ unregister(name) {
223
+ if (name in builtinFunctions) {
224
+ throw new Error(`Cannot unregister built-in function "${name}"`);
225
+ }
226
+ return delete functions[name];
227
+ },
228
+
229
+ /**
230
+ * Lists all public function names (excludes internal `__` operators).
231
+ * @returns {string[]}
232
+ */
233
+ list() {
234
+ return Object.keys(functions).filter(name => !name.startsWith('__'));
235
+ },
236
+
237
+ /**
238
+ * Returns names and descriptions of all public functions
239
+ * (excludes internal `__` operators).
240
+ * @returns {Array<{name: string, description: string}>}
241
+ */
242
+ describe() {
243
+ return Object.entries(functions)
244
+ .filter(([name]) => !name.startsWith('__'))
245
+ .map(([name, { description }]) => ({ name, description }));
246
+ },
247
+
248
+ /**
249
+ * Returns a shallow copy of all registered function definitions.
250
+ * @returns {Record<string, FunctionDef>}
251
+ */
252
+ getAll() {
253
+ return { ...functions };
254
+ },
255
+ };
256
+ }
package/src/index.js CHANGED
@@ -1,24 +1,43 @@
1
1
  /**
2
- * FormulaEvaluator: A lightweight, extensible formula engine
3
- * Features: Variable tracking, nested functions, and operator precedence.
2
+ * @module formula-evaluator
3
+ * A lightweight, extensible formula engine with variable tracking,
4
+ * nested functions, and operator precedence.
5
+ */
6
+
7
+ import { createFunctionRegistry } from './functions.js';
8
+
9
+ export { builtinFunctions, createFunctionRegistry } from './functions.js';
10
+
11
+ /**
12
+ * Evaluates string-based formulas with support for variables, functions,
13
+ * and arithmetic operators.
14
+ *
15
+ * @example
16
+ * const evaluator = new FormulaEvaluator({ tax: 0.2 });
17
+ * evaluator.registerFunction('discount', (price, pct) => price * (1 - pct));
18
+ * evaluator.evaluate('discount(100, tax)'); // 80
4
19
  */
5
20
  class FormulaEvaluator {
21
+ /**
22
+ * Creates a new FormulaEvaluator instance.
23
+ *
24
+ * @param {Record<string, *>} [globalContext={}] - Variables available to every evaluation
25
+ */
6
26
  constructor(globalContext = {}) {
27
+ /** @type {Record<string, *>} */
7
28
  this.context = globalContext;
8
29
 
9
- // Core Function Library (Easily extensible)
10
- this.FUNCTIONS = {
11
- upper: (str) => String(str).toUpperCase(),
12
- join: (sep, ...args) => args.join(sep),
13
- sum: (...args) => args.reduce((a, b) => Number(a) + Number(b), 0),
14
- avg: (...args) => args.reduce((a, b) => a + b, 0) / args.length,
15
- if: (cond, a, b) => (cond ? a : b),
16
- // Internal Operator Mappings
17
- __add: (a, b) => a + b,
18
- __sub: (a, b) => a - b,
19
- __eq: (a, b) => a === b,
20
- };
30
+ /**
31
+ * Internal function registry.
32
+ * @private
33
+ * @type {import('./functions.js').FunctionRegistry}
34
+ */
35
+ this._functions = createFunctionRegistry();
21
36
 
37
+ /**
38
+ * Token type constants used by the tokenizer.
39
+ * @type {Readonly<Record<string, string>>}
40
+ */
22
41
  this.TOKEN_TYPES = {
23
42
  NUMBER: 'number',
24
43
  STRING: 'string',
@@ -29,13 +48,62 @@ class FormulaEvaluator {
29
48
  };
30
49
  }
31
50
 
51
+ /**
52
+ * Registers a custom function that can be called in formulas.
53
+ *
54
+ * @param {string} name - The function name (used in formula strings)
55
+ * @param {import('./functions.js').FormulaFunction} fn - The function implementation
56
+ * @param {string} [description=''] - A human-readable description of the function
57
+ * @returns {this} The evaluator instance, for chaining
58
+ * @throws {Error} If name is not a non-empty string or fn is not a function
59
+ *
60
+ * @example
61
+ * evaluator
62
+ * .registerFunction('double', (x) => x * 2, 'Doubles a number')
63
+ * .registerFunction('clamp', (val, min, max) => Math.min(Math.max(val, min), max), 'Restricts a number to a range');
64
+ *
65
+ * evaluator.evaluate('double(5)'); // 10
66
+ * evaluator.evaluate('clamp(15, 0, 10)'); // 10
67
+ */
68
+ registerFunction(name, fn, description = '') {
69
+ this._functions.register(name, fn, description);
70
+ return this;
71
+ }
72
+
73
+ /**
74
+ * Lists the names of all registered public functions
75
+ * (excludes internal operator mappings).
76
+ *
77
+ * @returns {string[]} Array of function names
78
+ */
79
+ listFunctions() {
80
+ return this._functions.list();
81
+ }
82
+
83
+ /**
84
+ * Returns the names and descriptions of all registered public functions
85
+ * (excludes internal operator mappings).
86
+ *
87
+ * @returns {Array<{name: string, description: string}>}
88
+ */
89
+ describeFunctions() {
90
+ return this._functions.describe();
91
+ }
92
+
93
+ /**
94
+ * Tokenizes a formula string into an array of tokens.
95
+ *
96
+ * @param {string} str - The formula string to tokenize
97
+ * @returns {Array<{type: string, value: string, start: number, end: number}>} The token array
98
+ * @throws {Error} If an unexpected character is encountered
99
+ */
32
100
  tokenize(str) {
33
101
  const tokens = [];
34
102
  const rules = [
35
103
  { type: this.TOKEN_TYPES.STRING, regex: /"([^"]*)"/g },
36
104
  { type: this.TOKEN_TYPES.NUMBER, regex: /\d*\.?\d+/g },
37
105
  { type: this.TOKEN_TYPES.IDENTIFIER, regex: /[a-zA-Z][\w\d]*/g },
38
- { type: this.TOKEN_TYPES.OPERATOR, regex: /[+=-]/g },
106
+ { type: this.TOKEN_TYPES.OPERATOR, regex: />=|<=|[+=\-><]/g },
39
107
  { type: this.TOKEN_TYPES.DELIMITER, regex: /[(),]/g },
40
108
  { type: this.TOKEN_TYPES.WHITESPACE, regex: /\s+/g }
41
109
  ];
@@ -65,7 +133,12 @@ class FormulaEvaluator {
65
133
  return tokens;
66
134
  }
67
135
 
68
- // --- Parser Logic ---
136
+ /**
137
+ * Parses an array of tokens into an abstract syntax tree (AST).
138
+ *
139
+ * @param {Array<{type: string, value: string}>} tokens - Tokens produced by {@link tokenize}
140
+ * @returns {*} The root AST node
141
+ */
69
142
  parse(tokens) {
70
143
  let pos = 0;
71
144
 
@@ -76,7 +149,7 @@ class FormulaEvaluator {
76
149
  if (currentToken && currentToken.type === this.TOKEN_TYPES.OPERATOR) {
77
150
  const opToken = tokens[pos++];
78
151
  const right = parseExpression();
79
- const opMap = { '+': '__add', '-': '__sub', '=': '__eq' };
152
+ const opMap = { '+': '__add', '-': '__sub', '=': '__eq', '>': '__gt', '>=': '__gte', '<': '__lt', '<=': '__lte' };
80
153
  return { type: 'function', name: opMap[opToken.value], args: [node, right] };
81
154
  }
82
155
  return node;
@@ -116,7 +189,19 @@ class FormulaEvaluator {
116
189
  return parseExpression();
117
190
  }
118
191
 
119
- // --- Execution & Analysis ---
192
+ /**
193
+ * Evaluates a formula string and returns the result.
194
+ *
195
+ * @param {string} formula - The formula to evaluate
196
+ * @param {Record<string, *>} [localContext={}] - Variables scoped to this evaluation
197
+ * (overrides global context for matching keys)
198
+ * @returns {*} The evaluation result
199
+ * @throws {Error} If a referenced variable or function is not found
200
+ *
201
+ * @example
202
+ * evaluator.evaluate('sum(1, 2, 3)'); // 6
203
+ * evaluator.evaluate('x + 1', { x: 10 }); // 11
204
+ */
120
205
  evaluate(formula, localContext = {}) {
121
206
  const ast = this.parse(this.tokenize(formula));
122
207
  const ctx = { ...this.context, ...localContext };
@@ -130,7 +215,16 @@ class FormulaEvaluator {
130
215
  }
131
216
 
132
217
  if (node.type === 'function') {
133
- const fn = this.FUNCTIONS[node.name];
218
+ // iferr requires lazy evaluation: catch errors from the first arg
219
+ if (node.name === 'iferr') {
220
+ try {
221
+ return run(node.args[0]);
222
+ } catch {
223
+ return run(node.args[1]);
224
+ }
225
+ }
226
+
227
+ const fn = this._functions.get(node.name);
134
228
  if (!fn) throw new Error(`Function "${node.name}" not found`);
135
229
  return fn(...node.args.map(run));
136
230
  }
@@ -139,6 +233,15 @@ class FormulaEvaluator {
139
233
  return run(ast);
140
234
  }
141
235
 
236
+ /**
237
+ * Returns the variable names referenced in a formula (excludes function names).
238
+ *
239
+ * @param {string} formula - The formula to analyze
240
+ * @returns {string[]} Deduplicated array of variable names
241
+ *
242
+ * @example
243
+ * evaluator.getDependencies('sum(x, y) + z'); // ['x', 'y', 'z']
244
+ */
142
245
  getDependencies(formula) {
143
246
  const ast = this.parse(this.tokenize(formula));
144
247
  const deps = new Set();