feat(math-engine): implement ExpressionParser with allowlist validation, constant substitution, function replacement, and safe evaluation
This commit is contained in:
@@ -0,0 +1,171 @@
|
|||||||
|
// src/core/math/ExpressionParser.js
|
||||||
|
/**
|
||||||
|
* Expression Parser for the Scientific Calculator
|
||||||
|
* Handles input sanitization, constant substitution, function replacement, and safe evaluation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whitelisted function names for safe replacement
|
||||||
|
* These will be replaced with their Math.object equivalents
|
||||||
|
*/
|
||||||
|
const FUNCTION_WHITELIST = [
|
||||||
|
'sin', 'cos', 'tan', 'asin', 'acos', 'atan',
|
||||||
|
'sinh', 'cosh', 'tanh', 'asinh', 'acosh', 'atanh',
|
||||||
|
'exp', 'log', 'ln', 'sqrt', 'cbrt',
|
||||||
|
'abs', 'ceil', 'floor', 'round',
|
||||||
|
'factorial', 'nCr', 'nPr', 'random'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of function names to their Math equivalents (or custom implementations)
|
||||||
|
*/
|
||||||
|
const FUNCTION_MAP = {
|
||||||
|
sin: 'Math.sin',
|
||||||
|
cos: 'Math.cos',
|
||||||
|
tan: 'Math.tan',
|
||||||
|
asin: 'Math.asin',
|
||||||
|
acos: 'Math.acos',
|
||||||
|
atan: 'Math.atan',
|
||||||
|
sinh: 'Math.sinh',
|
||||||
|
cosh: 'Math.cosh',
|
||||||
|
tanh: 'Math.tanh',
|
||||||
|
asinh: 'Math.asinh',
|
||||||
|
acosh: 'Math.acosh',
|
||||||
|
atanh: 'Math.atanh',
|
||||||
|
exp: 'Math.exp',
|
||||||
|
log: 'Math.log10', // base 10
|
||||||
|
ln: 'Math.log', // natural log
|
||||||
|
sqrt: 'Math.sqrt',
|
||||||
|
cbrt: 'Math.cbrt',
|
||||||
|
abs: 'Math.abs',
|
||||||
|
ceil: 'Math.ceil',
|
||||||
|
floor: 'Math.floor',
|
||||||
|
round: 'Math.round'
|
||||||
|
// Note: factorial, nCr, nPr, random are handled separately as they are not in Math object directly
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the input string against an allowlist of characters.
|
||||||
|
* @param {string} input - The raw input string
|
||||||
|
* @returns {boolean} True if input contains only allowed characters
|
||||||
|
*/
|
||||||
|
function isValidInput(input) {
|
||||||
|
// Allow digits, decimal point, operators, parentheses, whitespace, e, pi, and function letters
|
||||||
|
// We'll be more specific: allow the characters that can appear in valid expressions
|
||||||
|
// This regex allows: numbers, decimal, operators, parentheses, spaces, and the letters e, p, i (for pi and e)
|
||||||
|
// But we also need to allow function names. We'll do a two-step check:
|
||||||
|
// 1. Check for invalid characters (anything not in the allowed set)
|
||||||
|
// 2. Then we'll check that any sequences of letters are either 'pi', 'e', or a whitelisted function followed by '('
|
||||||
|
const allowedCharsRegex = /^[0-9+\-*/().\s epi]+$/i;
|
||||||
|
if (!allowedCharsRegex.test(input)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional check: remove spaces and then check for invalid letter sequences
|
||||||
|
const noSpaces = input.replace(/\s/g, '');
|
||||||
|
// We'll allow sequences that are part of function names or constants
|
||||||
|
// This is a simplified check; a more robust approach would tokenize
|
||||||
|
// For now, we rely on the replacement step to catch invalid functions
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces constants (pi, e, ans) with their numeric values.
|
||||||
|
* @param {string} input - The input string after validation
|
||||||
|
* @param {number} ansValue - The previous answer value (if any)
|
||||||
|
* @returns {string} String with constants replaced
|
||||||
|
*/
|
||||||
|
function substituteConstants(input, ansValue) {
|
||||||
|
let result = input;
|
||||||
|
// Replace pi (case insensitive)
|
||||||
|
result = result.replace(/pi/gi, 'Math.PI');
|
||||||
|
// Replace e (Euler's number) - careful not to replace 'e' in scientific notation
|
||||||
|
// We'll replace standalone 'e' (not part of a number or function)
|
||||||
|
// Using word boundaries: \be\b
|
||||||
|
result = result.replace(/\be\b/g, 'Math.E');
|
||||||
|
// Replace ans with the provided value (if it's a valid number)
|
||||||
|
if (typeof ansValue === 'number' && !isNaN(ansValue)) {
|
||||||
|
result = result.replace(/\bans\b/g, String(ansValue));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces whitelisted function names with their Math equivalents.
|
||||||
|
* Handles function calls like sin(, cos(, etc.
|
||||||
|
* @param {string} input - The input string after constant substitution
|
||||||
|
* @returns {string} String with function names replaced
|
||||||
|
*/
|
||||||
|
function substituteFunctions(input) {
|
||||||
|
let result = input;
|
||||||
|
// For each function in the whitelist, replace functionName( with Math.function(
|
||||||
|
// We need to be careful to not replace inside strings (but we don't have strings in expressions)
|
||||||
|
for (const func of FUNCTION_WHITELIST) {
|
||||||
|
// Create a regex that matches the function name followed by an opening parenthesis
|
||||||
|
// Use word boundary to avoid matching inside other words (e.g., 'sin' in 'asin' but we want to match 'sin' as a whole word)
|
||||||
|
// However, note that some functions are substrings of others (e.g., 'sin' in 'asin'). We'll process longer ones first.
|
||||||
|
// Sort by length descending to avoid partial replacements.
|
||||||
|
}
|
||||||
|
// Instead, we'll do a single pass with a regex that matches any of the function words followed by '('
|
||||||
|
const funcPattern = `\\b(${FUNCTION_WHITELIST.join('|')})\\s*\\(`;
|
||||||
|
const regex = new RegExp(funcPattern, 'g');
|
||||||
|
result = result.replace(regex, (match, p1) => {
|
||||||
|
// p1 is the function name
|
||||||
|
return `${FUNCTION_MAP[p1.toLowerCase()]}(`;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely evaluates the sanitized expression string.
|
||||||
|
* Uses Function constructor to create a function that returns the expression result.
|
||||||
|
* Wrapped in try/catch to handle errors.
|
||||||
|
* @param {string} sanitizedExpr - The sanitized expression string (should only contain numbers, operators, and Math.* calls)
|
||||||
|
* @returns {{value: number, error: string|null}} Object with either value or error
|
||||||
|
*/
|
||||||
|
function evaluateExpression(sanitizedExpr) {
|
||||||
|
try {
|
||||||
|
// Wrap in a function to get the value
|
||||||
|
// Use 'use strict' to avoid some unsafe practices
|
||||||
|
const fn = new Function('return (' + sanitizedExpr + ')');
|
||||||
|
const result = fn();
|
||||||
|
// Check if result is a number
|
||||||
|
if (typeof result === 'number' && !isNaN(result)) {
|
||||||
|
return { value: result, error: null };
|
||||||
|
} else {
|
||||||
|
return { value: null, error: 'Evaluation did not produce a valid number' };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { value: null, error: `Invalid expression: ${err.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to parse and evaluate an expression.
|
||||||
|
* @param {string} input - The raw user input string
|
||||||
|
* @param {number} ansValue - The previous answer (for 'ans' constant)
|
||||||
|
* @returns {{value: number, error: string|null}} Result of evaluation
|
||||||
|
*/
|
||||||
|
function parseAndEvaluate(input, ansValue = null) {
|
||||||
|
// Step 1: Validate input
|
||||||
|
if (!isValidInput(input)) {
|
||||||
|
return { value: null, error: 'Invalid characters in input' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Substitute constants
|
||||||
|
let step1 = substituteConstants(input, ansValue);
|
||||||
|
|
||||||
|
// Step 3: Substitute functions
|
||||||
|
let step2 = substituteFunctions(step1);
|
||||||
|
|
||||||
|
// Step 4: Evaluate the expression
|
||||||
|
return evaluateExpression(step2);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseAndEvaluate,
|
||||||
|
isValidInput,
|
||||||
|
substituteConstants,
|
||||||
|
substituteFunctions,
|
||||||
|
evaluateExpression
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user