export type CellValueReader = (viewRow: number, col: number) => unknown; export type FormulaResult = number | 'DIV_ZERO' | null; class DivZeroError extends Error { constructor() { super('Division by zero'); this.name = 'DivZeroError'; } } export function colLettersToIndex(letters: string): number { let n = 0; for (const ch of letters.toUpperCase()) { n = n * 26 + (ch.charCodeAt(0) - 64); } return n - 1; } export function colIndexToLetters(index: number): string { let n = index + 1; let result = ''; while (n > 0) { n -= 1; result = String.fromCharCode(65 + (n % 26)) + result; n = Math.floor(n / 26); } return result; } function toNumber(value: unknown): number { if (value == null || value === '') return 0; const n = Number(value); return Number.isFinite(n) ? n : Number.NaN; } function tokenize(expr: string): string[] { const tokens: string[] = []; let i = 0; while (i < expr.length) { const ch = expr[i]!; if (ch === ' ' || ch === '\t') { i += 1; continue; } if ('+-*/()'.includes(ch)) { tokens.push(ch); i += 1; continue; } if (/[0-9.]/.test(ch)) { let num = ch; i += 1; while (i < expr.length && /[0-9.]/.test(expr[i]!)) { num += expr[i]; i += 1; } tokens.push(num); continue; } if (/[A-Za-z]/.test(ch)) { let ref = ch; i += 1; while (i < expr.length && /[A-Za-z0-9]/.test(expr[i]!)) { ref += expr[i]; i += 1; } tokens.push(ref); continue; } throw new Error(`Unexpected character: ${ch}`); } return tokens; } function parseFactor( tokens: string[], pos: { i: number }, readCell: CellValueReader, viewRow: number, ): number { const token = tokens[pos.i]; if (token === undefined) throw new Error('Unexpected end'); if (token === '(') { pos.i += 1; const value = parseExpression(tokens, pos, readCell, viewRow); if (tokens[pos.i] !== ')') throw new Error('Missing )'); pos.i += 1; return value; } if (token === '+') { pos.i += 1; return parseFactor(tokens, pos, readCell, viewRow); } if (token === '-') { pos.i += 1; return -parseFactor(tokens, pos, readCell, viewRow); } if (/^[0-9]+(\.[0-9]+)?$/.test(token)) { pos.i += 1; return Number(token); } const match = /^([A-Za-z]+)(\d+)$/.exec(token); if (match) { pos.i += 1; const col = colLettersToIndex(match[1]!); return toNumber(readCell(viewRow, col)); } throw new Error(`Invalid token: ${token}`); } function parseTerm( tokens: string[], pos: { i: number }, readCell: CellValueReader, viewRow: number, ): number { let value = parseFactor(tokens, pos, readCell, viewRow); while (pos.i < tokens.length) { const op = tokens[pos.i]; if (op === '*' || op === '/') { pos.i += 1; const rhs = parseFactor(tokens, pos, readCell, viewRow); if (op === '/') { if (rhs === 0) throw new DivZeroError(); value /= rhs; } else { value *= rhs; } } else { break; } } return value; } function parseExpression( tokens: string[], pos: { i: number }, readCell: CellValueReader, viewRow: number, ): number { let value = parseTerm(tokens, pos, readCell, viewRow); while (pos.i < tokens.length) { const op = tokens[pos.i]; if (op === '+' || op === '-') { pos.i += 1; const rhs = parseTerm(tokens, pos, readCell, viewRow); value = op === '+' ? value + rhs : value - rhs; } else { break; } } return value; } export function evaluateFormula( formula: string, viewRow: number, readCell: CellValueReader, ): FormulaResult { if (!formula?.trim()) return null; let expr = formula.trim(); if (expr.startsWith('=')) expr = expr.slice(1); if (!expr) return null; try { const tokens = tokenize(expr); const pos = { i: 0 }; const result = parseExpression(tokens, pos, readCell, viewRow); if (pos.i < tokens.length) throw new Error('Trailing tokens'); if (!Number.isFinite(result)) return null; return result; } catch (err) { if (err instanceof DivZeroError) return 'DIV_ZERO'; return null; } } export function formatFormulaResult(value: FormulaResult): string { if (value === 'DIV_ZERO') return '-'; if (value == null || Number.isNaN(value)) return '#ERROR'; if (Number.isInteger(value)) return String(value); return String(Math.round(value * 100) / 100); }