import Decimal from "decimal.js"; import type { EvaluatedField, FormulaAnalysis, FormulaEngine, FormulaError, FormulaErrorCode, FormulaEvaluation, FormulaField, FormulaOptions, } from "./types"; const DeterministicDecimal = Decimal.clone({ precision: 100, rounding: Decimal.ROUND_HALF_UP, toExpNeg: -1_000, toExpPos: 1_000, }); type TokenType = | "number" | "reference" | "plus" | "minus" | "multiply" | "divide" | "left-paren" | "right-paren" | "percent" | "eof"; interface Token { type: TokenType; lexeme: string; offset: number; } type Expression = | { type: "number"; value: string } | { type: "reference"; key: string } | { type: "unary"; operator: "+" | "-"; operand: Expression } | { type: "percent"; operand: Expression } | { type: "binary"; operator: "+" | "-" | "*" | "/"; left: Expression; right: Expression; }; class FormulaFailure extends Error { constructor( readonly code: FormulaErrorCode, message: string, readonly reference?: string, readonly causeCode?: FormulaErrorCode, ) { super(message); this.name = "FormulaFailure"; } } function isIdentifierStart(character: string): boolean { return /[\p{L}_]/u.test(character); } function isIdentifierPart(character: string): boolean { return /[\p{L}\p{M}\p{N}_]/u.test(character); } function tokenize(source: string): Token[] { const tokens: Token[] = []; let offset = 0; while (offset < source.length) { const character = source[offset]; if (/\s/u.test(character)) { offset += 1; continue; } if (/\d/u.test(character) || (character === "." && /\d/u.test(source[offset + 1] ?? ""))) { const start = offset; let sawDecimalPoint = false; while (offset < source.length) { const next = source[offset]; if (/\d/u.test(next)) { offset += 1; continue; } if (next === "." && !sawDecimalPoint) { sawDecimalPoint = true; offset += 1; continue; } break; } const lexeme = source.slice(start, offset); if (lexeme.endsWith(".")) { throw new FormulaFailure( "parse-error", `Decimal literal at position ${start + 1} is incomplete.`, ); } tokens.push({ type: "number", lexeme, offset: start }); continue; } if (isIdentifierStart(character)) { const start = offset; offset += 1; while ( offset < source.length && isIdentifierPart(source[offset] ?? "") ) { offset += 1; } tokens.push({ type: "reference", lexeme: source.slice(start, offset), offset: start, }); continue; } const operators: Record = { "+": "plus", "-": "minus", "*": "multiply", "/": "divide", "(": "left-paren", ")": "right-paren", "%": "percent", }; const type = operators[character]; if (!type) { throw new FormulaFailure( "parse-error", `Unsupported token “${character}” at position ${offset + 1}.`, ); } tokens.push({ type, lexeme: character, offset }); offset += 1; } tokens.push({ type: "eof", lexeme: "", offset: source.length }); return tokens; } class Parser { private cursor = 0; constructor(private readonly tokens: readonly Token[]) {} parse(): Expression { const expression = this.parseAdditive(); const trailing = this.peek(); if (trailing.type !== "eof") { throw new FormulaFailure( "parse-error", `Unexpected token “${trailing.lexeme}” at position ${trailing.offset + 1}.`, ); } return expression; } private parseAdditive(): Expression { let expression = this.parseMultiplicative(); while (this.matches("plus", "minus")) { const operator = this.previous().lexeme as "+" | "-"; expression = { type: "binary", operator, left: expression, right: this.parseMultiplicative(), }; } return expression; } private parseMultiplicative(): Expression { let expression = this.parseUnary(); while (this.matches("multiply", "divide")) { const operator = this.previous().lexeme as "*" | "/"; expression = { type: "binary", operator, left: expression, right: this.parseUnary(), }; } return expression; } private parseUnary(): Expression { if (this.matches("plus", "minus")) { return { type: "unary", operator: this.previous().lexeme as "+" | "-", operand: this.parseUnary(), }; } return this.parsePercent(); } private parsePercent(): Expression { let expression = this.parsePrimary(); while (this.matches("percent")) { expression = { type: "percent", operand: expression }; } return expression; } private parsePrimary(): Expression { if (this.matches("number")) { return { type: "number", value: this.previous().lexeme }; } if (this.matches("reference")) { const reference = this.previous(); if (this.peek().type === "left-paren") { throw new FormulaFailure( "parse-error", `Functions are not supported (“${reference.lexeme}” at position ${reference.offset + 1}).`, ); } return { type: "reference", key: reference.lexeme }; } if (this.matches("left-paren")) { const expression = this.parseAdditive(); this.consume("right-paren", "Expected a closing parenthesis."); return expression; } const token = this.peek(); throw new FormulaFailure( "parse-error", token.type === "eof" ? "Expected a number, field reference, or parenthesized expression." : `Unexpected token “${token.lexeme}” at position ${token.offset + 1}.`, ); } private consume(type: TokenType, message: string): Token { if (this.peek().type === type) return this.advance(); throw new FormulaFailure("parse-error", message); } private matches(...types: TokenType[]): boolean { if (!types.includes(this.peek().type)) return false; this.advance(); return true; } private advance(): Token { if (this.peek().type !== "eof") this.cursor += 1; return this.previous(); } private peek(): Token { return this.tokens[this.cursor] ?? this.tokens[this.tokens.length - 1]; } private previous(): Token { return this.tokens[Math.max(0, this.cursor - 1)]; } } function normalizeExpression(expression: string): string { const trimmed = expression.trim(); return trimmed.startsWith("=") ? trimmed.slice(1).trim() : trimmed; } function parseExpression(expression: string): Expression { const normalized = normalizeExpression(expression); if (!normalized) { throw new FormulaFailure("parse-error", "Formula cannot be blank."); } return new Parser(tokenize(normalized)).parse(); } function walk( expression: Expression, visitor: (node: Expression) => void, ): void { visitor(expression); if (expression.type === "binary") { walk(expression.left, visitor); walk(expression.right, visitor); } else if (expression.type === "unary" || expression.type === "percent") { walk(expression.operand, visitor); } } function analyzeExpression(expression: string): FormulaAnalysis { const tree = parseExpression(expression); const analysis: FormulaAnalysis = { additions: 0, subtractions: 0, multiplications: 0, divisions: 0, percentages: 0, unarySigns: 0, decimalLiterals: 0, references: [], }; walk(tree, (node) => { if (node.type === "number" && node.value.includes(".")) { analysis.decimalLiterals += 1; } else if (node.type === "reference") { if (!analysis.references.includes(node.key)) { analysis.references.push(node.key); } } else if (node.type === "percent") { analysis.percentages += 1; } else if (node.type === "unary") { analysis.unarySigns += 1; } else if (node.type === "binary") { if (node.operator === "+") analysis.additions += 1; if (node.operator === "-") analysis.subtractions += 1; if (node.operator === "*") analysis.multiplications += 1; if (node.operator === "/") analysis.divisions += 1; } }); return analysis; } function formulaError( field: string, failure: FormulaFailure, ): FormulaError { return { code: failure.code, field, reference: failure.reference, cause: failure.causeCode, message: failure.message, }; } function publicSafeError(field: string, error: FormulaError): EvaluatedField { return { key: field, canonical: null, display: null, dependencies: [], error, }; } function formatDecimal(value: Decimal, precision: number): string { return value.toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toFixed(precision); } function assertPrecision(value: number): number { if (!Number.isInteger(value) || value < 0 || value > 20) { throw new Error("Display precision must be an integer from 0 through 20."); } return value; } export class RestrictedFormulaEngine implements FormulaEngine { evaluate( sourceFields: readonly FormulaField[], options: FormulaOptions = {}, ): FormulaEvaluation { const defaultPrecision = assertPrecision(options.defaultDisplayPrecision ?? 2); const fields = new Map(); for (const field of sourceFields) { if (!field.key || fields.has(field.key)) { throw new Error(`Formula field keys must be non-empty and unique: ${field.key}`); } fields.set(field.key, field); } const results = new Map(); const decimals = new Map(); const parsed = new Map(); const visiting = new Set(); const stack: string[] = []; const order: string[] = []; const resolveReference = (reference: string): string => options.referenceAliases?.[reference] ?? reference; const evaluateTree = ( tree: Expression, currentField: string, dependencies: Set, ): Decimal => { if (tree.type === "number") { try { return new DeterministicDecimal(tree.value); } catch { throw new FormulaFailure( "invalid-number", `“${tree.value}” is not a valid decimal number.`, ); } } if (tree.type === "reference") { const reference = resolveReference(tree.key); dependencies.add(reference); if (!fields.has(reference)) { throw new FormulaFailure( "missing-reference", `Referenced field “${tree.key}” does not exist.`, tree.key, ); } evaluateField(reference); const dependency = results.get(reference); const value = decimals.get(reference); if (!dependency || dependency.error || !value) { const cause = dependency?.error?.code ?? "blank-reference"; throw new FormulaFailure( cause === "blank-reference" ? "blank-reference" : "dependency-error", cause === "blank-reference" ? `Referenced field “${tree.key}” is blank.` : `Referenced field “${tree.key}” could not be calculated.`, tree.key, cause, ); } return value; } if (tree.type === "unary") { const value = evaluateTree(tree.operand, currentField, dependencies); return tree.operator === "-" ? value.negated() : value; } if (tree.type === "percent") { return evaluateTree(tree.operand, currentField, dependencies).dividedBy(100); } const left = evaluateTree(tree.left, currentField, dependencies); const right = evaluateTree(tree.right, currentField, dependencies); if (tree.operator === "+") return left.plus(right); if (tree.operator === "-") return left.minus(right); if (tree.operator === "*") return left.times(right); if (right.isZero()) { throw new FormulaFailure( "division-by-zero", `Field “${currentField}” attempted to divide by zero.`, ); } return left.dividedBy(right); }; const evaluateField = (key: string): void => { if (results.has(key)) return; if (visiting.has(key)) { const cycleStart = stack.indexOf(key); const cycle = stack.slice(cycleStart); for (const cycleKey of cycle) { results.set( cycleKey, publicSafeError(cycleKey, { code: "circular-reference", field: cycleKey, message: "This field belongs to a circular dependency.", }), ); } return; } const field = fields.get(key); if (!field) return; visiting.add(key); stack.push(key); const dependencies = new Set(); try { let value: Decimal; const expression = field.formula?.trim(); if (expression) { let tree = parsed.get(key); if (!tree) { tree = parseExpression(expression); parsed.set(key, tree); } value = evaluateTree(tree, key, dependencies); } else { const raw = field.value?.trim(); if (!raw) { throw new FormulaFailure( "blank-reference", `Field “${key}” is blank.`, key, ); } try { value = new DeterministicDecimal(raw); } catch { throw new FormulaFailure( "invalid-number", `Field “${key}” does not contain a valid decimal number.`, key, ); } } // A cycle may have been recorded while resolving a child reference. if (!results.has(key)) { const precision = assertPrecision( field.displayPrecision ?? defaultPrecision, ); decimals.set(key, value); results.set(key, { key, canonical: value.toString(), display: formatDecimal(value, precision), dependencies: [...dependencies], }); order.push(key); } } catch (error) { if (!results.has(key)) { const failure = error instanceof FormulaFailure ? error : new FormulaFailure( "parse-error", error instanceof Error ? error.message : "Formula evaluation failed.", ); const result = publicSafeError(key, formulaError(key, failure)); result.dependencies = [...dependencies]; results.set(key, result); } } finally { stack.pop(); visiting.delete(key); } }; for (const key of fields.keys()) evaluateField(key); return { fields: Object.fromEntries(results), order, valid: [...results.values()].every((field) => !field.error), }; } analyze(expression: string): FormulaAnalysis { return analyzeExpression(expression); } } export const formulaEngine: FormulaEngine = new RestrictedFormulaEngine();