mulmoclaude 2.7.0 → 2.8.1

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.
Files changed (77) hide show
  1. package/client/assets/Graph-ChAxkn9o.js +1 -0
  2. package/client/assets/dist-Bij44LdC.js +2 -0
  3. package/client/assets/echarts-GV3U9sVf.js +1 -0
  4. package/client/assets/echarts-gl-BXx4On0Y.js +4282 -0
  5. package/client/assets/echarts-wordcloud-_9uQUAfp.js +1 -0
  6. package/client/assets/emscripten-module-Q67P5WYC-BU3Hlttz.js +2160 -0
  7. package/client/assets/ffi-4V2b7xu3.js +1 -0
  8. package/client/assets/{index-lkpgbKtk.js → index-D6xzCZhP.js} +600 -589
  9. package/client/assets/index-DzMsXDnj.css +2 -0
  10. package/client/assets/{install-wxcddUsO.js → install-C1moNcrn.js} +1 -1
  11. package/client/assets/{marp-DcTywifN.js → marp-Du5Sym5b.js} +1 -1
  12. package/client/assets/module-ES6BEMUI-CDTIpMiA.js +6 -0
  13. package/client/index.html +4 -3
  14. package/package.json +5 -4
  15. package/server/api/routes/plugins.ts +8 -59
  16. package/server/build/mcp-server.mjs +609 -608
  17. package/server/index.ts +1 -0
  18. package/server/plugins/confined-file-ops.ts +136 -0
  19. package/server/plugins/runtime.ts +8 -115
  20. package/server/plugins/spreadsheet-builtin.ts +26 -0
  21. package/server/plugins/spreadsheet-save-outcome.ts +7 -0
  22. package/server/utils/files/store-resolvers.ts +1 -1
  23. package/src/lang/de.ts +0 -20
  24. package/src/lang/en.ts +0 -20
  25. package/src/lang/es.ts +0 -20
  26. package/src/lang/fr.ts +0 -20
  27. package/src/lang/ja.ts +0 -20
  28. package/src/lang/ko.ts +0 -20
  29. package/src/lang/pt-BR.ts +0 -20
  30. package/src/lang/zh.ts +0 -20
  31. package/src/plugins/spreadsheet/definition.ts +9 -127
  32. package/src/plugins/spreadsheet/index.ts +7 -4
  33. package/src/plugins/spreadsheet/meta.ts +2 -4
  34. package/client/assets/echarts-gl-Cq76Z6DA.js +0 -4282
  35. package/client/assets/index-Cz7JLE0k.css +0 -2
  36. package/server/utils/files/spreadsheet-store.ts +0 -26
  37. package/src/plugins/spreadsheet/Preview.vue +0 -31
  38. package/src/plugins/spreadsheet/View.vue +0 -1007
  39. package/src/plugins/spreadsheet/cellHighlights.ts +0 -78
  40. package/src/plugins/spreadsheet/engine/calculator.ts +0 -427
  41. package/src/plugins/spreadsheet/engine/cellBuilder.ts +0 -89
  42. package/src/plugins/spreadsheet/engine/cellEmpty.ts +0 -26
  43. package/src/plugins/spreadsheet/engine/cellFormatting.ts +0 -59
  44. package/src/plugins/spreadsheet/engine/coerce-boolean.ts +0 -26
  45. package/src/plugins/spreadsheet/engine/condition.ts +0 -182
  46. package/src/plugins/spreadsheet/engine/date-locale.ts +0 -47
  47. package/src/plugins/spreadsheet/engine/date-parser.ts +0 -234
  48. package/src/plugins/spreadsheet/engine/date-utils.ts +0 -73
  49. package/src/plugins/spreadsheet/engine/datedif.ts +0 -93
  50. package/src/plugins/spreadsheet/engine/engine.ts +0 -177
  51. package/src/plugins/spreadsheet/engine/evaluator.ts +0 -494
  52. package/src/plugins/spreadsheet/engine/financial-math.ts +0 -114
  53. package/src/plugins/spreadsheet/engine/formatter.ts +0 -158
  54. package/src/plugins/spreadsheet/engine/formulaError.ts +0 -67
  55. package/src/plugins/spreadsheet/engine/formulaRefs.ts +0 -202
  56. package/src/plugins/spreadsheet/engine/functions/date.ts +0 -212
  57. package/src/plugins/spreadsheet/engine/functions/financial.ts +0 -244
  58. package/src/plugins/spreadsheet/engine/functions/index.ts +0 -16
  59. package/src/plugins/spreadsheet/engine/functions/logical.ts +0 -221
  60. package/src/plugins/spreadsheet/engine/functions/lookup-math.ts +0 -28
  61. package/src/plugins/spreadsheet/engine/functions/lookup.ts +0 -257
  62. package/src/plugins/spreadsheet/engine/functions/mathematical.ts +0 -283
  63. package/src/plugins/spreadsheet/engine/functions/statistical-math.ts +0 -91
  64. package/src/plugins/spreadsheet/engine/functions/statistical.ts +0 -290
  65. package/src/plugins/spreadsheet/engine/functions/text.ts +0 -398
  66. package/src/plugins/spreadsheet/engine/index.ts +0 -34
  67. package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +0 -111
  68. package/src/plugins/spreadsheet/engine/math-ops.ts +0 -98
  69. package/src/plugins/spreadsheet/engine/numericCoercion.ts +0 -61
  70. package/src/plugins/spreadsheet/engine/parser.ts +0 -40
  71. package/src/plugins/spreadsheet/engine/registry.ts +0 -201
  72. package/src/plugins/spreadsheet/engine/responseDecoder.ts +0 -68
  73. package/src/plugins/spreadsheet/engine/spreadsheet-errors.ts +0 -82
  74. package/src/plugins/spreadsheet/engine/textFormat.ts +0 -108
  75. package/src/plugins/spreadsheet/engine/translateFormula.ts +0 -69
  76. package/src/plugins/spreadsheet/engine/types.ts +0 -81
  77. package/src/plugins/spreadsheet/keyboardNav.ts +0 -38
@@ -1,177 +0,0 @@
1
- /**
2
- * SpreadsheetEngine - Main API
3
- *
4
- * High-level interface for spreadsheet calculations
5
- */
6
-
7
- import { calculateSheet, calculateWorkbook } from "./calculator";
8
- import type { SheetData, CalculatedSheet, EngineOptions, SpreadsheetCell } from "./types";
9
- import { isObj } from "../../../utils/types";
10
-
11
- /**
12
- * SpreadsheetEngine - Main calculation engine class
13
- *
14
- * Provides a clean API for calculating spreadsheet formulas with support for:
15
- * - Formula evaluation (SUM, AVERAGE, IF, etc.)
16
- * - Cell references (A1, $B$2, Sheet1!C3)
17
- * - Cross-sheet references
18
- * - Number formatting ($#,##0.00, 0.00%, etc.)
19
- * - Circular reference detection
20
- *
21
- * @example
22
- * ```typescript
23
- * const engine = new SpreadsheetEngine();
24
- * const sheet = {
25
- * name: 'Sales',
26
- * data: [
27
- * [{v: 'Product'}, {v: 'Price'}, {v: 'Qty'}, {v: 'Total'}],
28
- * [{v: 'Widget'}, {v: 10}, {v: 100}, {v: '=B2*C2'}],
29
- * ]
30
- * };
31
- * const result = engine.calculate(sheet);
32
- * console.log(result.data); // [['Product', 'Price', 'Qty', 'Total'], ['Widget', 10, 100, 1000]]
33
- * ```
34
- */
35
- export class SpreadsheetEngine {
36
- private options: Required<EngineOptions>;
37
-
38
- /**
39
- * Create a new SpreadsheetEngine
40
- *
41
- * @param options - Configuration options
42
- */
43
- constructor(options: EngineOptions = {}) {
44
- this.options = {
45
- maxIterations: options.maxIterations ?? 100,
46
- enableCrossSheetRefs: options.enableCrossSheetRefs ?? true,
47
- strictMode: options.strictMode ?? false,
48
- preferDDMMYYYY: options.preferDDMMYYYY ?? false,
49
- };
50
- }
51
-
52
- /**
53
- * Calculate a single sheet
54
- *
55
- * Evaluates all formulas in the sheet and applies number formatting.
56
- * Returns calculated values with formula metadata and any errors.
57
- *
58
- * @param sheet - Sheet data with formulas
59
- * @param allSheets - Optional array of all sheets for cross-sheet references
60
- * @returns Calculated sheet with evaluated formulas
61
- *
62
- * @example
63
- * ```typescript
64
- * const result = engine.calculate({
65
- * name: 'Budget',
66
- * data: [
67
- * [{v: 'Item'}, {v: 'Amount'}],
68
- * [{v: 'Revenue'}, {v: 1000}],
69
- * [{v: 'Expenses'}, {v: 600}],
70
- * [{v: 'Profit'}, {v: '=B2-B3'}],
71
- * ]
72
- * });
73
- * console.log(result.data[3][1]); // 400
74
- * ```
75
- */
76
- calculate(sheet: SheetData, allSheets?: SheetData[]): CalculatedSheet {
77
- return calculateSheet(sheet, allSheets, { preferDDMMYYYY: this.options.preferDDMMYYYY });
78
- }
79
-
80
- /**
81
- * Calculate all sheets in a workbook
82
- *
83
- * Evaluates formulas across multiple sheets with support for
84
- * cross-sheet references (e.g., Sheet1!A1).
85
- *
86
- * @param sheets - Array of sheets to calculate
87
- * @returns Array of calculated sheets
88
- *
89
- * @example
90
- * ```typescript
91
- * const results = engine.calculateWorkbook([
92
- * { name: 'Data', data: [[{v: 100}]] },
93
- * { name: 'Summary', data: [[{v: '=Data!A1*2'}]] }
94
- * ]);
95
- * console.log(results[1].data[0][0]); // 200
96
- * ```
97
- */
98
- calculateWorkbook(sheets: SheetData[]): CalculatedSheet[] {
99
- return calculateWorkbook(sheets, { preferDDMMYYYY: this.options.preferDDMMYYYY });
100
- }
101
-
102
- /**
103
- * Get current engine options
104
- *
105
- * @returns Current configuration options
106
- */
107
- getOptions(): Required<EngineOptions> {
108
- return { ...this.options };
109
- }
110
-
111
- /**
112
- * Update engine options
113
- *
114
- * @param options - Options to update
115
- *
116
- * @example
117
- * ```typescript
118
- * engine.setOptions({ strictMode: true });
119
- * ```
120
- */
121
- setOptions(options: Partial<EngineOptions>): void {
122
- this.options = {
123
- ...this.options,
124
- ...options,
125
- };
126
- }
127
-
128
- /**
129
- * Create a simple sheet from array data
130
- *
131
- * Helper method to create a SheetData object from simple arrays.
132
- * Automatically converts values to SpreadsheetCell format.
133
- *
134
- * @param name - Sheet name
135
- * @param data - Array of arrays (rows and cells)
136
- * @returns SheetData object
137
- *
138
- * @example
139
- * ```typescript
140
- * const sheet = engine.createSheet('Sales', [
141
- * ['Product', 'Price', 'Qty', 'Total'],
142
- * ['Widget', 10, 100, '=B2*C2'],
143
- * ]);
144
- * ```
145
- */
146
- createSheet(name: string, data: Array<Array<SpreadsheetCell | string | number>>): SheetData {
147
- return {
148
- name,
149
- data: data.map((row) =>
150
- row.map((cell) => {
151
- if (isObj(cell) && "v" in cell) {
152
- return cell as SpreadsheetCell;
153
- }
154
- return { v: cell };
155
- }),
156
- ),
157
- };
158
- }
159
-
160
- /**
161
- * Convert calculated sheet data to string array
162
- *
163
- * Helper method for testing and output formatting.
164
- *
165
- * @param calculated - Calculated sheet
166
- * @returns 2D array of strings
167
- *
168
- * @example
169
- * ```typescript
170
- * const result = engine.calculate(sheet);
171
- * const stringArray = engine.toStringArray(result);
172
- * ```
173
- */
174
- toStringArray(calculated: CalculatedSheet): string[][] {
175
- return calculated.data.map((row) => row.map((cell) => String(cell ?? "")));
176
- }
177
- }
@@ -1,494 +0,0 @@
1
- /**
2
- * Formula Evaluator
3
- *
4
- * Evaluates spreadsheet formulas including functions, cell references, and arithmetic
5
- */
6
-
7
- import { functionRegistry, isCallableName, tooFewArgumentsError } from "./registry";
8
- import type { CellValue } from "./types";
9
- import { parseDate } from "./date-parser";
10
- import { caretToPow, replaceConcatOperator, rewriteComparisonEq, isSafeArithmetic, isSafeComparison } from "./translateFormula";
11
- import { divZeroError, unknownError, nameError, propagatedError } from "./formulaError";
12
- import { errorCodeOf, isSpreadsheetErrorValue } from "./spreadsheet-errors";
13
-
14
- /**
15
- * Evaluation context for formulas
16
- */
17
- export interface EvaluatorContext {
18
- getCellValue: (ref: string) => CellValue;
19
- getRangeValues: (range: string) => CellValue[];
20
- getRangeValuesRaw?: ((range: string) => CellValue[]) | undefined;
21
- evaluateFormula: (formula: string) => CellValue;
22
- /** Reading order for an ambiguous slash date; see engine/date-locale.ts. */
23
- preferDDMMYYYY?: boolean | undefined;
24
- }
25
-
26
- /** Render a cell's value as the text that stands in for it inside an
27
- * expression. Strings are quoted so they cannot be read as identifiers or
28
- * operators, and their own quotes and backslashes are escaped so the literal
29
- * cannot be closed early. A missing value becomes 0, matching how blanks are
30
- * treated everywhere else in the engine. */
31
- export function renderOperand(value: CellValue | null | undefined): string {
32
- if (value === null || value === undefined) return "0";
33
- const text = isSpreadsheetErrorValue(value) ? value.code : value;
34
- if (typeof text === "string") return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
35
- return text.toString();
36
- }
37
-
38
- /** Replace every quoted string literal with an empty pair of quotes, honouring
39
- * `\` escapes so an escaped quote does not end the literal early. Used to
40
- * validate the STRUCTURE of a concat/arithmetic expression without letting the
41
- * arbitrary CONTENT of a string decide whether the whole thing looks safe. */
42
- export function maskStringLiterals(expr: string): string {
43
- let out = "";
44
- let quote: string | null = null;
45
- for (let index = 0; index < expr.length; index++) {
46
- const char = expr[index];
47
- if (quote !== null) {
48
- if (char === "\\") {
49
- index++; // skip the escaped character — it is part of the literal
50
- continue;
51
- }
52
- if (char === quote) {
53
- quote = null;
54
- out += char;
55
- }
56
- continue;
57
- }
58
- if (char === '"' || char === "'") {
59
- quote = char;
60
- out += char;
61
- continue;
62
- }
63
- out += char;
64
- }
65
- return out;
66
- }
67
-
68
- /** Whether a `&`-to-`+` concatenation expression is safe to evaluate. Checks the
69
- * structure with string CONTENT masked out — a string literal may hold any
70
- * character (a `!`, a `\`), and validating those against a character allowlist
71
- * rejected valid formulas like `=A1&"!"` and every escaped operand. Once the
72
- * literals are masked, only the joining structure remains to validate. */
73
- export function isSafeConcatExpression(expr: string): boolean {
74
- // A boolean cell renders as a bare `true` / `false` here (renderOperand) —
75
- // the only non-literal identifier the substitution can emit. Drop those words
76
- // before validating so a boolean operand (`=A1&"!"` with A1 = true) still
77
- // evaluates, while any other identifier keeps the structure from matching and
78
- // never reaches `new Function`.
79
- const structure = maskStringLiterals(expr).replace(/\b(?:true|false)\b/g, "");
80
- return /^[\d+\-*/(). "']*$/.test(structure);
81
- }
82
-
83
- /** Index just past the string literal that opens at `start` (a quote char),
84
- * honouring `\` escapes so an escaped quote does not close it early. Returns
85
- * `expr.length` when the literal is never closed. */
86
- export function endOfStringLiteral(expr: string, start: number): number {
87
- const quote = expr[start];
88
- let index = start + 1;
89
- while (index < expr.length) {
90
- if (expr[index] === "\\") {
91
- index += 2; // skip the escaped character — it is part of the literal
92
- continue;
93
- }
94
- if (expr[index] === quote) return index + 1;
95
- index++;
96
- }
97
- return expr.length;
98
- }
99
-
100
- /** A `'Sheet Name'!A1` reference beginning at `start` (a `'`), or null when the
101
- * quotes open a plain string literal rather than a sheet-qualified reference. */
102
- function matchQuotedSheetRef(expr: string, start: number): string | null {
103
- const endQuote = expr.indexOf("'", start + 1);
104
- if (endQuote === -1 || expr[endQuote + 1] !== "!") return null;
105
- const cellPart = expr.substring(endQuote + 2).match(/^(\$?[A-Z]+\$?\d+)/);
106
- if (!cellPart) return null;
107
- return expr.substring(start, endQuote + 2 + cellPart[0].length);
108
- }
109
-
110
- /** A `Sheet!A1` or bare `A1` / `$A$1` reference beginning at `start`, or null. */
111
- function matchUnquotedRef(expr: string, start: number): string | null {
112
- const rest = expr.substring(start);
113
- const sheetMatch = rest.match(/^([A-Z][A-Z0-9]*)!/i);
114
- if (sheetMatch) {
115
- const cellPart = rest.substring(sheetMatch[0].length).match(/^(\$?[A-Z]+\$?\d+)/);
116
- if (cellPart) return sheetMatch[0] + cellPart[0];
117
- }
118
- const cellMatch = rest.match(/^(\$?[A-Z]+\$?\d+)/);
119
- return cellMatch ? cellMatch[0] : null;
120
- }
121
-
122
- /** Every cell reference in an expression, as `{ref, start}` spans in source
123
- * order. Quoted string literals are skipped whole: a `"A1"` in the text is a
124
- * constant, not a reference, and substituting it would turn `="A1"&"!"` into
125
- * A1's value. A `'` opens either a `'Sheet'!A1` reference or a string literal —
126
- * only the former is a reference; the latter is skipped like a `"` literal. */
127
- export interface CellRefSpan {
128
- ref: string;
129
- start: number;
130
- }
131
-
132
- export function findCellRefs(expr: string): CellRefSpan[] {
133
- const cellRefs: CellRefSpan[] = [];
134
- let i = 0;
135
- while (i < expr.length) {
136
- const char = expr[i];
137
- if (char === '"') {
138
- i = endOfStringLiteral(expr, i);
139
- continue;
140
- }
141
- if (char === "'") {
142
- const sheetRef = matchQuotedSheetRef(expr, i);
143
- if (sheetRef) {
144
- cellRefs.push({ ref: sheetRef, start: i });
145
- i += sheetRef.length;
146
- } else {
147
- i = endOfStringLiteral(expr, i);
148
- }
149
- continue;
150
- }
151
- const ref = matchUnquotedRef(expr, i);
152
- if (ref) {
153
- cellRefs.push({ ref, start: i });
154
- i += ref.length;
155
- continue;
156
- }
157
- i++;
158
- }
159
- return cellRefs;
160
- }
161
-
162
- /** Replace every reference span in `expr` with the text `render` gives for it,
163
- * walking BACK TO FRONT so the spans ahead of each edit stay valid. A global
164
- * string replace rewrote every occurrence of the shorter reference first, so
165
- * `=A1+A10` had its `A10` broken into `<A1's value>0` and produced a plausible
166
- * wrong number (#2357). */
167
- export function substituteCellRefs(expr: string, cellRefs: CellRefSpan[], render: (ref: string) => string): string {
168
- return [...cellRefs].reverse().reduce((text, { ref, start }) => text.slice(0, start) + render(ref) + text.slice(start + ref.length), expr);
169
- }
170
-
171
- /**
172
- * Parse function arguments, handling nested functions and quoted strings
173
- *
174
- * @param argsStr - String containing function arguments
175
- * @returns Array of argument strings
176
- */
177
- export function parseFunctionArgs(argsStr: string): string[] {
178
- const args: string[] = [];
179
- let currentArg = "";
180
- let depth = 0;
181
- let inString = false;
182
- let stringChar = "";
183
-
184
- for (let i = 0; i < argsStr.length; i++) {
185
- const char = argsStr[i];
186
- const prevChar = i > 0 ? argsStr[i - 1] : "";
187
-
188
- // Handle string boundaries
189
- if ((char === '"' || char === "'") && prevChar !== "\\") {
190
- if (!inString) {
191
- inString = true;
192
- stringChar = char;
193
- } else if (char === stringChar) {
194
- inString = false;
195
- stringChar = "";
196
- }
197
- currentArg += char;
198
- continue;
199
- }
200
-
201
- // Track parentheses depth (for nested functions)
202
- if (!inString) {
203
- if (char === "(") depth++;
204
- if (char === ")") depth--;
205
-
206
- // Split on comma only at depth 0 and not in string
207
- if (char === "," && depth === 0) {
208
- args.push(currentArg.trim());
209
- currentArg = "";
210
- continue;
211
- }
212
- }
213
-
214
- currentArg += char;
215
- }
216
-
217
- if (currentArg.trim()) {
218
- args.push(currentArg.trim());
219
- }
220
-
221
- return args;
222
- }
223
-
224
- /** Run `new Function` on an expression the callers have already gated with a
225
- * character allowlist. A JS parse/eval failure becomes #ERROR! — a genuinely
226
- * broken formula that used to be swallowed into a bare string (#2359). A
227
- * non-finite NUMBER result is only reachable by dividing by zero (comparisons
228
- * yield booleans, concatenation yields strings), so it becomes #DIV/0!. */
229
- function evalValidatedExpression(jsExpr: string): CellValue {
230
- let result: unknown;
231
- try {
232
- // eslint-disable -- sonarjs/code-eval
233
- result = new Function(`return (${jsExpr})`)();
234
- } catch {
235
- throw unknownError();
236
- }
237
- if (typeof result === "number") {
238
- if (!Number.isFinite(result)) throw divZeroError();
239
- return result;
240
- }
241
- if (typeof result === "string" || typeof result === "boolean") return result;
242
- throw unknownError();
243
- }
244
-
245
- /**
246
- * Evaluate a formula string
247
- *
248
- * Supports:
249
- * - Function calls: SUM(A1:A10), ROUND(B2, 2)
250
- * - Cell references: A1, B2, Sheet1!A1
251
- * - Arithmetic: 2+3, A1*B1, (A1+B1)/2
252
- * - Nested expressions: ROUND(SUM(A1:A10)/COUNT(A1:A10), 2)
253
- *
254
- * A genuine failure THROWS (a typed FormulaError or any handler error) rather
255
- * than returning the raw formula text; the calculator classifies it into
256
- * errors[] and shows the Excel error value in the cell (#2359).
257
- *
258
- * @param formula - Formula string (without leading =)
259
- * @param context - Evaluation context with cell/range accessors
260
- * @returns Evaluated result (number or string)
261
- */
262
- export function evaluateFormula(formula: string, context: EvaluatorContext): CellValue {
263
- // Handle string literals - remove surrounding quotes
264
- // But NOT string concatenations (which contain & operators)
265
- const trimmed = formula.trim();
266
- if (
267
- ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) &&
268
- !trimmed.includes("&") // Exclude string concatenations
269
- ) {
270
- const stringValue = trimmed.slice(1, -1); // Remove first and last character (quotes)
271
-
272
- // Auto-parse date strings to serial numbers for compatibility with date arithmetic
273
- // This allows formulas like =HLOOKUP("6/1/2024", ...) to work with parsed date cells
274
- const dateSerial = parseDate(stringValue, context.preferDDMMYYYY);
275
- if (dateSerial !== null) {
276
- return dateSerial;
277
- }
278
-
279
- return stringValue;
280
- }
281
-
282
- // Check if it's a SIMPLE function call (not a complex expression)
283
- // We need to ensure the formula is JUST a function, not "FUNC(...) + something"
284
- const [, funcName, argsStr] = formula.match(/^([A-Z][A-Z0-9]*)\((.*)\)$/i) ?? [];
285
- if (funcName !== undefined && argsStr !== undefined && isCallableName(funcName)) {
286
- // Check that the closing paren is actually the end of the function
287
- // by counting parentheses in argsStr
288
- let parenDepth = 0;
289
- let isValidFunction = true;
290
- for (const char of argsStr) {
291
- if (char === "(") parenDepth++;
292
- else if (char === ")") {
293
- parenDepth--;
294
- if (parenDepth < 0) {
295
- // More closing parens than opening - this means we matched too much
296
- isValidFunction = false;
297
- break;
298
- }
299
- }
300
- }
301
-
302
- // Normalize function name to uppercase for registry lookup
303
- const normalizedFuncName = funcName.toUpperCase();
304
- const func = functionRegistry.get(normalizedFuncName);
305
-
306
- if (func && isValidFunction) {
307
- const args = parseFunctionArgs(argsStr);
308
-
309
- // Validate argument count
310
- if (func.minArgs !== undefined && args.length < func.minArgs) {
311
- throw tooFewArgumentsError(normalizedFuncName, func.minArgs);
312
- }
313
- if (func.maxArgs !== undefined && args.length > func.maxArgs) {
314
- throw new Error(`${normalizedFuncName} accepts at most ${func.maxArgs} argument${func.maxArgs !== 1 ? "s" : ""}`);
315
- }
316
-
317
- // Execute function with context
318
- return func.handler(args, {
319
- functionName: normalizedFuncName,
320
- getCellValue: context.getCellValue,
321
- getRangeValues: context.getRangeValues,
322
- getRangeValuesRaw: context.getRangeValuesRaw,
323
- evaluateFormula: context.evaluateFormula,
324
- });
325
- }
326
-
327
- // A balanced `NAME(...)` spanning the whole formula whose NAME is not a
328
- // registered function is an unrecognized name — Excel's #NAME?. Surfacing
329
- // it here also stops the arithmetic path below from recursing on it forever
330
- // and leaving the literal text in the cell (#2359).
331
- if (isValidFunction && !func) {
332
- throw nameError(normalizedFuncName);
333
- }
334
- }
335
-
336
- // Handle simple arithmetic expressions with cell references
337
- // First, replace any function calls within the expression
338
- let expr = formula;
339
-
340
- // Find and evaluate function calls (e.g., TODAY(), SUM(A1:A10), LOWER(A1), etc.)
341
- // Use a simpler approach: find function names followed by parentheses
342
- // and manually parse the matching closing parenthesis
343
- let searchIndex = 0;
344
- const maxIterations = 100; // Prevent infinite loops
345
- let iterations = 0;
346
-
347
- while (searchIndex < expr.length && iterations < maxIterations) {
348
- iterations++;
349
- // A call written inside a string literal is text, not a call.
350
- if (expr[searchIndex] === '"' || expr[searchIndex] === "'") {
351
- searchIndex = endOfStringLiteral(expr, searchIndex);
352
- continue;
353
- }
354
- const [, funcName] = expr.substring(searchIndex).match(/^([A-Z][A-Z0-9]*)\(/i) ?? [];
355
- if (funcName === undefined || !isCallableName(funcName)) {
356
- // No more functions found, move to next character
357
- searchIndex++;
358
- if (searchIndex >= expr.length) break;
359
- continue;
360
- }
361
-
362
- const funcStartIndex = searchIndex;
363
- const argsStartIndex = searchIndex + funcName.length + 1;
364
-
365
- // Find matching closing parenthesis
366
- let depth = 1;
367
- let argsEndIndex = argsStartIndex;
368
- let inString = false;
369
- let stringChar = "";
370
-
371
- while (argsEndIndex < expr.length && depth > 0) {
372
- const char = expr[argsEndIndex];
373
- const prevChar = argsEndIndex > 0 ? expr[argsEndIndex - 1] : "";
374
-
375
- // Track string boundaries
376
- if ((char === '"' || char === "'") && prevChar !== "\\") {
377
- if (!inString) {
378
- inString = true;
379
- stringChar = char;
380
- } else if (char === stringChar) {
381
- inString = false;
382
- stringChar = "";
383
- }
384
- }
385
-
386
- // Only count parens outside of strings
387
- if (!inString) {
388
- if (char === "(") depth++;
389
- else if (char === ")") depth--;
390
- }
391
- argsEndIndex++;
392
- }
393
-
394
- if (depth === 0) {
395
- const fullMatch = expr.substring(funcStartIndex, argsEndIndex);
396
- const result = context.evaluateFormula(fullMatch);
397
- // A nested call that errored poisons the expression the same way an
398
- // errored reference does — substituting it produced `"#NUM!"+1` text.
399
- // Only the error VALUE counts: text that merely spells an error (CONCAT)
400
- // is an ordinary operand.
401
- if (isSpreadsheetErrorValue(result)) throw propagatedError(result.code);
402
- // For string results, wrap in quotes; for numbers, wrap in parentheses
403
- const replacement = typeof result === "string" ? `"${result}"` : `(${result})`;
404
- expr = expr.substring(0, funcStartIndex) + replacement + expr.substring(argsEndIndex);
405
- // Continue from after the replacement
406
- searchIndex = funcStartIndex + replacement.length;
407
- } else {
408
- searchIndex++;
409
- }
410
- }
411
-
412
- // Then replace cell references with their values. Detection skips quoted
413
- // string literals so a `"A1"` constant is not read as a reference.
414
- const cellRefs = findCellRefs(expr);
415
-
416
- // A formula that is nothing but one reference returns that cell's value
417
- // directly. Rendering it into an expression first would mean escaping the
418
- // text, and the escapes would survive into the result — `=A1` on a cell
419
- // holding `say "hi"` would come back `say \"hi\"`. Surrounding whitespace
420
- // (`= A1`, `=A1 `) is part of "nothing but one reference", so compare the
421
- // span against the trimmed expression rather than the raw one.
422
- const soleRef = cellRefs.length === 1 ? cellRefs[0] : undefined;
423
- if (soleRef) {
424
- const before = expr.slice(0, soleRef.start);
425
- const after = expr.slice(soleRef.start + soleRef.ref.length);
426
- if (before.trim() === "" && after.trim() === "") {
427
- return context.getCellValue(soleRef.ref);
428
- }
429
- }
430
-
431
- expr = substituteCellRefs(expr, cellRefs, (ref) => {
432
- const value = context.getCellValue(ref);
433
- // A referenced cell holding an error poisons the whole expression:
434
- // rendering it would produce `"#DIV/0!"+1` garbage, so propagate the error
435
- // instead of substituting it (#2359, Excel behaviour). A cell whose stored
436
- // TEXT spells an error counts here too — Excel stores a typed error literal
437
- // as an error, and arithmetic over it is never meaningful.
438
- const referencedErrorCode = errorCodeOf(value);
439
- if (referencedErrorCode !== null) throw propagatedError(referencedErrorCode);
440
- return renderOperand(value);
441
- });
442
-
443
- // Parse date strings in arithmetic expressions (e.g., "06/01/2025" → serial number)
444
- // This allows formulas like =B3-"06/01/2025" to work correctly
445
- expr = expr.replace(/"([^"]+)"/g, (match, dateStr) => {
446
- const dateSerial = parseDate(dateStr, context.preferDDMMYYYY);
447
- if (dateSerial !== null) {
448
- return dateSerial.toString();
449
- }
450
- return match; // Keep original if not a date
451
- });
452
-
453
- // Replace ^ with ** for exponentiation
454
- expr = caretToPow(expr);
455
-
456
- // Check if this is a string concatenation expression (contains & and quoted strings)
457
- const hasStringConcat = expr.includes("&");
458
- const hasQuotedStrings = /["']/.test(expr);
459
-
460
- // If it contains string concatenation, handle it specially. Convert & to +
461
- // for JS concatenation (leaving any & inside a literal untouched), then
462
- // validate the STRUCTURE with literals masked — a literal may hold any
463
- // character, so masking is what lets `=A1&"!"` and escaped operands through
464
- // while still rejecting a genuinely unsafe expression. A non-safe structure
465
- // falls through to the paths below rather than erroring.
466
- if (hasStringConcat && hasQuotedStrings) {
467
- const result = replaceConcatOperator(expr);
468
- if (isSafeConcatExpression(result)) {
469
- return evalValidatedExpression(result);
470
- }
471
- }
472
-
473
- // Safely evaluate comparison expressions (e.g., 5=6, (5)>(6)). The allowlist
474
- // (numbers, `= != < > <= >=`, parens, whitespace) gates the evaluation. It is
475
- // a superset of the arithmetic allowlist, so a plain `1/0` is handled here —
476
- // evalValidatedExpression turns its Infinity into #DIV/0! (#2359).
477
- if (isSafeComparison(expr)) {
478
- return evalValidatedExpression(rewriteComparisonEq(expr));
479
- }
480
-
481
- // Safely evaluate arithmetic expressions (numbers, operators, parens,
482
- // whitespace, decimal points).
483
- if (isSafeArithmetic(expr)) {
484
- return evalValidatedExpression(expr);
485
- }
486
-
487
- // If the final expression is a quoted string literal, unwrap it
488
- const trimmedExpr = expr.trim();
489
- if ((trimmedExpr.startsWith('"') && trimmedExpr.endsWith('"')) || (trimmedExpr.startsWith("'") && trimmedExpr.endsWith("'"))) {
490
- return trimmedExpr.slice(1, -1); // Remove quotes
491
- }
492
-
493
- return expr; // Return processed expression (with cell refs replaced, etc.)
494
- }