@lotics/xlsx 0.1.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.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,401 @@
1
+ // =============================================================================
2
+ // Formula Engine
3
+ // =============================================================================
4
+ //
5
+ // Orchestrates formula evaluation using:
6
+ // - fast-formula-parser for parsing and evaluating Excel formulas
7
+ // - DependencyGraph for tracking cell dependencies and recalculation order
8
+ // - WorkbookModel as the cell value source via callbacks
9
+ //
10
+ // Lifecycle:
11
+ // 1. On workbook load: build dependency graph from all formula cells
12
+ // 2. Evaluate cells with missing cached values
13
+ // 3. On cell edit: update graph, recalculate affected dependents
14
+ // =============================================================================
15
+
16
+ /// <reference path="./fast-formula-parser.d.ts" />
17
+ import FormulaParser from "fast-formula-parser";
18
+ import { DependencyGraph, cellKey, parseCellKey } from "./formula_deps";
19
+ import type { WorkbookModel, CellValue } from "./workbook_model";
20
+ import { refToRowCol, rowColToRef, colLettersToNum } from "./excel_utils";
21
+ import { formatNumFmt } from "./excel_numfmt";
22
+ import { expandStructuredRefs } from "./structured_refs";
23
+
24
+ // =============================================================================
25
+ // FormulaEngine
26
+ // =============================================================================
27
+
28
+ export class FormulaEngine {
29
+ private graph = new DependencyGraph();
30
+ private parser: FormulaParser;
31
+ private depParser: InstanceType<typeof FormulaParser.DepParser>;
32
+ private workbook: WorkbookModel;
33
+
34
+ constructor(workbook: WorkbookModel) {
35
+ this.workbook = workbook;
36
+
37
+ this.parser = new FormulaParser({
38
+ onCell: (ref: { row: number; col: number; sheet?: string }) => this.resolveCell(ref),
39
+ onRange: (ref: { from: { row: number; col: number }; to: { row: number; col: number }; sheet?: string }) => this.resolveRange(ref),
40
+ onVariable: (name: string) => this.resolveVariable(name),
41
+ });
42
+
43
+ this.depParser = new FormulaParser.DepParser();
44
+ }
45
+
46
+ // ===========================================================================
47
+ // Initialization — build graph and evaluate missing values
48
+ // ===========================================================================
49
+
50
+ /** Build dependency graph from all formula cells in the workbook. */
51
+ buildGraph(): void {
52
+ this.graph.clear();
53
+ for (const sheet of this.workbook.sheets) {
54
+ for (const [ref, cell] of sheet.cells) {
55
+ if (!cell.formula) continue;
56
+ const key = cellKey({ sheet: sheet.name, ref });
57
+ const deps = this.extractDependencies(cell.formula, sheet.name);
58
+ this.graph.setDependencies(key, deps);
59
+ }
60
+ }
61
+ }
62
+
63
+ /** Evaluate all formula cells that have no cached value (value is null). */
64
+ evaluateMissing(): void {
65
+ const missing: string[] = [];
66
+ for (const sheet of this.workbook.sheets) {
67
+ for (const [ref, cell] of sheet.cells) {
68
+ if (cell.formula && cell.value === null) {
69
+ missing.push(cellKey({ sheet: sheet.name, ref }));
70
+ }
71
+ }
72
+ }
73
+ this.evaluateSet(missing);
74
+ }
75
+
76
+ /**
77
+ * Evaluate every formula cell, ignoring any previously cached value.
78
+ *
79
+ * `evaluateMissing` trusts cached values — the right default for loading
80
+ * a file that was produced by a correct writer. Use `evaluateAll` when
81
+ * the cached values are known to be stale, e.g. after a template
82
+ * substitution pipeline has rewritten the cells a formula references
83
+ * but didn't recompute the formula itself. Excel does this on every
84
+ * file open; our writer doesn't, so downstream consumers that trust
85
+ * `<v>` (our renderer, PDF converters, print-to-image) would otherwise
86
+ * show stale values.
87
+ */
88
+ evaluateAll(): void {
89
+ const all: string[] = [];
90
+ for (const sheet of this.workbook.sheets) {
91
+ for (const [ref, cell] of sheet.cells) {
92
+ if (cell.formula) {
93
+ all.push(cellKey({ sheet: sheet.name, ref }));
94
+ }
95
+ }
96
+ }
97
+ this.evaluateSet(all);
98
+ }
99
+
100
+ private evaluateSet(keys: string[]): void {
101
+ if (keys.length === 0) return;
102
+ const order = this.graph.getRecalcOrder(keys);
103
+ const orderSet = new Set(order);
104
+ const toEvaluate = [...keys.filter((k) => !orderSet.has(k)), ...order];
105
+ for (const key of toEvaluate) {
106
+ this.evaluateCell(key);
107
+ }
108
+ }
109
+
110
+ // ===========================================================================
111
+ // Cell edit — update graph and recalculate
112
+ // ===========================================================================
113
+
114
+ /** Called when a cell value changes. Recalculates all affected dependents. */
115
+ onCellChanged(sheetName: string, ref: string, formula?: string): void {
116
+ const key = cellKey({ sheet: sheetName, ref });
117
+
118
+ if (formula) {
119
+ const deps = this.extractDependencies(formula, sheetName);
120
+ this.graph.setDependencies(key, deps);
121
+
122
+ const cycle = this.graph.detectCycle(key);
123
+ if (cycle) {
124
+ this.setCellError(key, "#CIRC!");
125
+ return;
126
+ }
127
+
128
+ this.evaluateCell(key);
129
+ } else {
130
+ this.graph.removeCell(key);
131
+ }
132
+
133
+ const order = this.graph.getRecalcOrder([key]);
134
+ for (const depKey of order) {
135
+ const depCycle = this.graph.detectCycle(depKey);
136
+ if (depCycle) {
137
+ this.setCellError(depKey, "#CIRC!");
138
+ continue;
139
+ }
140
+ this.evaluateCell(depKey);
141
+ }
142
+ }
143
+
144
+ // ===========================================================================
145
+ // Formula evaluation (boundary handler — catches library errors)
146
+ // ===========================================================================
147
+
148
+ /** Evaluate a single formula cell and update its value. */
149
+ private evaluateCell(key: string): void {
150
+ const { sheet: sheetName, ref } = parseCellKey(key);
151
+ const sheetIndex = this.workbook.sheets.findIndex((s) => s.name === sheetName);
152
+ if (sheetIndex < 0) return;
153
+
154
+ const sheet = this.workbook.sheets[sheetIndex];
155
+ const cell = sheet.getCell(ref);
156
+ if (!cell?.formula) return;
157
+
158
+ const rc = refToRowCol(ref);
159
+ if (!rc) return;
160
+
161
+ // Expand structured references before evaluation
162
+ const expandedFormula = expandStructuredRefs(cell.formula, sheet.tables, rc.row);
163
+
164
+ // fast-formula-parser uses exceptions as its error-reporting API
165
+ // (FormulaError for #REF!, #VALUE!, etc.). This catch is a boundary
166
+ // adapter for the library, not business logic error swallowing.
167
+ try {
168
+ const raw = this.parser.parse(expandedFormula, {
169
+ sheet: sheetName,
170
+ row: rc.row,
171
+ col: rc.col,
172
+ });
173
+
174
+ const result = normalizeResult(raw);
175
+ cell.value = result.value;
176
+ cell.error = result.error;
177
+ cell.displayValue = result.error ?? computeDisplay(result.value, cell.numFmtCode, this.workbook.date1904);
178
+ cell.content = { type: "plain", text: cell.displayValue };
179
+ } catch (err: unknown) {
180
+ // Only catch FormulaError (library's error-reporting mechanism).
181
+ // Rethrow genuine bugs (TypeError, RangeError, etc.).
182
+ if (isFormulaError(err)) {
183
+ const errorName = err.name;
184
+ cell.error = errorName;
185
+ cell.value = null;
186
+ cell.displayValue = errorName;
187
+ cell.content = { type: "plain", text: errorName };
188
+ } else {
189
+ throw err;
190
+ }
191
+ }
192
+ }
193
+
194
+ private setCellError(key: string, error: string): void {
195
+ const { sheet: sheetName, ref } = parseCellKey(key);
196
+ const sheetIndex = this.workbook.sheets.findIndex((s) => s.name === sheetName);
197
+ if (sheetIndex < 0) return;
198
+ const cell = this.workbook.sheets[sheetIndex].getCell(ref);
199
+ if (!cell) return;
200
+ cell.error = error;
201
+ cell.value = null;
202
+ cell.displayValue = error;
203
+ cell.content = { type: "plain", text: error };
204
+ }
205
+
206
+ // ===========================================================================
207
+ // Dependency extraction
208
+ // ===========================================================================
209
+
210
+ /** Extract cell/range references from a formula string. Returns empty array for unparseable formulas. */
211
+ extractDependencies(formula: string, currentSheet: string): string[] {
212
+ const sheet = this.workbook.sheets.find((s) => s.name === currentSheet);
213
+ const expanded = sheet ? expandStructuredRefs(formula, sheet.tables, 1) : formula;
214
+ const deps = this.depParser.parse(expanded, { sheet: currentSheet, row: 1, col: 1 });
215
+ const refs: string[] = [];
216
+
217
+ for (const dep of deps) {
218
+ const depSheet = dep.sheet ?? currentSheet;
219
+ if (dep.from && dep.to) {
220
+ const maxCells = 10000;
221
+ let count = 0;
222
+ for (let r = dep.from.row; r <= dep.to.row && count < maxCells; r++) {
223
+ for (let c = dep.from.col; c <= dep.to.col && count < maxCells; c++) {
224
+ refs.push(cellKey({ sheet: depSheet, ref: rowColToRef(r, c) }));
225
+ count++;
226
+ }
227
+ }
228
+ } else if (dep.row != null && dep.col != null) {
229
+ refs.push(cellKey({ sheet: depSheet, ref: rowColToRef(dep.row, dep.col) }));
230
+ }
231
+ }
232
+
233
+ return refs;
234
+ }
235
+
236
+ // ===========================================================================
237
+ // Cell value resolution callbacks
238
+ // ===========================================================================
239
+
240
+ private resolveCell(ref: { row: number; col: number; sheet?: string }): CellValue {
241
+ const sheetName = ref.sheet ?? this.workbook.sheets[0]?.name ?? "Sheet1";
242
+ const sheet = this.workbook.sheets.find((s) => s.name === sheetName);
243
+ if (!sheet) return null;
244
+
245
+ const cellRef = rowColToRef(ref.row, ref.col);
246
+ const cell = sheet.getCell(cellRef);
247
+ if (!cell) return null;
248
+ if (cell.error) throw new Error(cell.error);
249
+ return cell.value;
250
+ }
251
+
252
+ private resolveRange(ref: {
253
+ from: { row: number; col: number };
254
+ to: { row: number; col: number };
255
+ sheet?: string;
256
+ }): CellValue[][] {
257
+ const sheetName = ref.sheet ?? this.workbook.sheets[0]?.name ?? "Sheet1";
258
+ const sheet = this.workbook.sheets.find((s) => s.name === sheetName);
259
+ if (!sheet) return [[]];
260
+
261
+ const result: CellValue[][] = [];
262
+ for (let r = ref.from.row; r <= ref.to.row; r++) {
263
+ const row: CellValue[] = [];
264
+ for (let c = ref.from.col; c <= ref.to.col; c++) {
265
+ const cell = sheet.getCell(rowColToRef(r, c));
266
+ row.push(cell?.value ?? null);
267
+ }
268
+ result.push(row);
269
+ }
270
+ return result;
271
+ }
272
+
273
+ // ===========================================================================
274
+ // Named range resolution
275
+ // ===========================================================================
276
+
277
+ private resolveVariable(name: string): unknown {
278
+ const rangeExpr = this.workbook.namedRanges.get(name);
279
+ if (rangeExpr === undefined) return null;
280
+
281
+ // Parse range expression like "Sheet1!$A$1:$B$5" or "Sheet1!$C$11"
282
+ const cleaned = rangeExpr.replace(/\$/g, "");
283
+ const sheetMatch = cleaned.match(/^(.+?)!(.+)$/);
284
+ const sheetName = sheetMatch ? sheetMatch[1].replace(/^'|'$/g, "") : this.workbook.sheets[0]?.name ?? "Sheet1";
285
+ const rangeStr = sheetMatch ? sheetMatch[2] : cleaned;
286
+
287
+ const sheet = this.workbook.sheets.find((s) => s.name === sheetName);
288
+ if (!sheet) return null;
289
+
290
+ // Check if it's a range (A1:B5) or single cell (C11)
291
+ const rangeMatch = rangeStr.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/);
292
+ if (rangeMatch) {
293
+ const fromRow = parseInt(rangeMatch[2], 10);
294
+ const fromCol = colLettersToNum(rangeMatch[1]);
295
+ const toRow = parseInt(rangeMatch[4], 10);
296
+ const toCol = colLettersToNum(rangeMatch[3]);
297
+ return this.resolveRange({
298
+ from: { row: fromRow, col: fromCol },
299
+ to: { row: toRow, col: toCol },
300
+ sheet: sheetName,
301
+ });
302
+ }
303
+
304
+ const cellMatch = rangeStr.match(/^([A-Z]+)(\d+)$/);
305
+ if (cellMatch) {
306
+ return this.resolveCell({
307
+ row: parseInt(cellMatch[2], 10),
308
+ col: colLettersToNum(cellMatch[1]),
309
+ sheet: sheetName,
310
+ });
311
+ }
312
+
313
+ return null;
314
+ }
315
+
316
+ // ===========================================================================
317
+ // Expression evaluation (for CF rules)
318
+ // ===========================================================================
319
+
320
+ /** Evaluate an arbitrary formula expression. Used by CF expression rules. */
321
+ evaluateExpression(formula: string, sheetName: string, row: number, col: number): unknown {
322
+ // fast-formula-parser uses exceptions as its error-reporting API
323
+ try {
324
+ return this.parser.parse(formula, { sheet: sheetName, row, col });
325
+ } catch (err: unknown) {
326
+ if (isFormulaError(err)) return null;
327
+ throw err;
328
+ }
329
+ }
330
+
331
+ /** Get the dependency graph (for testing/debugging). */
332
+ get deps(): DependencyGraph {
333
+ return this.graph;
334
+ }
335
+ }
336
+
337
+ // =============================================================================
338
+ // Helpers
339
+ // =============================================================================
340
+
341
+ interface FormulaResult {
342
+ value: CellValue;
343
+ error?: string;
344
+ }
345
+
346
+ /** Type guard for fast-formula-parser FormulaError objects. */
347
+ function isFormulaError(v: unknown): v is Error & { name: string } {
348
+ return v instanceof Error && typeof v.name === "string" && v.name.startsWith("#");
349
+ }
350
+
351
+ function normalizeResult(result: unknown): FormulaResult {
352
+ if (result === null || result === undefined) return { value: null };
353
+ if (typeof result === "number") return { value: result };
354
+ if (typeof result === "boolean") return { value: result };
355
+ if (typeof result === "string") return { value: result };
356
+ if (isFormulaError(result)) return { value: null, error: result.name };
357
+ // FormulaError objects that don't extend Error (library v1)
358
+ if (typeof result === "object" && result !== null && "name" in result) {
359
+ const name = (result as { name: unknown }).name;
360
+ if (typeof name === "string" && name.startsWith("#")) {
361
+ return { value: null, error: name };
362
+ }
363
+ }
364
+ return { value: String(result) };
365
+ }
366
+
367
+ function computeDisplay(value: CellValue, numFmtCode: string, date1904: boolean): string {
368
+ if (value === null) return "";
369
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
370
+ if (typeof value === "string") return value;
371
+ return formatNumFmt(value, numFmtCode, date1904).text;
372
+ }
373
+
374
+ // =============================================================================
375
+ // Convenience: load-time recompute
376
+ // =============================================================================
377
+
378
+ /**
379
+ * Build a dependency graph for the workbook and recompute every formula
380
+ * cell, overwriting whatever was cached. Use at boundaries where you
381
+ * just mutated inputs a formula depends on (template substitution, bulk
382
+ * paste) and need the downstream cached values to match.
383
+ */
384
+ export function recomputeAll(workbook: WorkbookModel): void {
385
+ const engine = new FormulaEngine(workbook);
386
+ engine.buildGraph();
387
+ engine.evaluateAll();
388
+ }
389
+
390
+ /**
391
+ * Refresh every formula cell on a freshly loaded workbook when the file
392
+ * declares `calcPr.fullCalcOnLoad="1"`. Mirrors Excel's behavior — the
393
+ * flag is a writer's signal that cached values can't be trusted, usually
394
+ * because the writer couldn't compute them (template pipelines, manual
395
+ * XML authoring, older tools). Safe to call on every load: a workbook
396
+ * without the flag or without formulas returns immediately.
397
+ */
398
+ export function recomputeIfStale(workbook: WorkbookModel): void {
399
+ if (!workbook.fullCalcOnLoad) return;
400
+ recomputeAll(workbook);
401
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { tokenizeFormula } from "./formula_highlight";
3
+ import type { FormulaToken } from "./formula_highlight";
4
+
5
+ function types(tokens: FormulaToken[]): string[] {
6
+ return tokens.map((t) => t.type);
7
+ }
8
+
9
+ function texts(tokens: FormulaToken[]): string[] {
10
+ return tokens.map((t) => t.text);
11
+ }
12
+
13
+ describe("tokenizeFormula", () => {
14
+ it("tokenizes a simple SUM formula", () => {
15
+ const tokens = tokenizeFormula("SUM(A1:B10)");
16
+ expect(texts(tokens)).toEqual(["SUM", "(", "A1:B10", ")"]);
17
+ expect(types(tokens)).toEqual(["function", "paren", "reference", "paren"]);
18
+ });
19
+
20
+ it("tokenizes IF with string and number", () => {
21
+ const tokens = tokenizeFormula('IF(A1>5,"Yes",0)');
22
+ expect(texts(tokens)).toEqual(["IF", "(", "A1", ">", "5", ",", '"Yes"', ",", "0", ")"]);
23
+ expect(types(tokens)).toEqual([
24
+ "function", "paren", "reference", "operator", "number",
25
+ "separator", "string", "separator", "number", "paren",
26
+ ]);
27
+ });
28
+
29
+ it("tokenizes absolute references", () => {
30
+ const tokens = tokenizeFormula("$A$1+B$2");
31
+ expect(texts(tokens)).toEqual(["$A$1", "+", "B$2"]);
32
+ expect(types(tokens)).toEqual(["reference", "operator", "reference"]);
33
+ });
34
+
35
+ it("tokenizes sheet-qualified references", () => {
36
+ const tokens = tokenizeFormula("Sheet1!A1+'Other Sheet'!$B$2");
37
+ expect(texts(tokens)).toEqual(["Sheet1!A1", "+", "'Other Sheet'!$B$2"]);
38
+ expect(types(tokens)).toEqual(["reference", "operator", "reference"]);
39
+ });
40
+
41
+ it("tokenizes nested functions", () => {
42
+ const tokens = tokenizeFormula("ROUND(SUM(A1:A10)/COUNT(A1:A10),2)");
43
+ expect(types(tokens)).toEqual([
44
+ "function", "paren", "function", "paren", "reference", "paren",
45
+ "operator", "function", "paren", "reference", "paren",
46
+ "separator", "number", "paren",
47
+ ]);
48
+ });
49
+
50
+ it("tokenizes multi-char operators", () => {
51
+ const tokens = tokenizeFormula("A1<>B1");
52
+ expect(texts(tokens)).toEqual(["A1", "<>", "B1"]);
53
+ expect(types(tokens)).toEqual(["reference", "operator", "reference"]);
54
+ });
55
+
56
+ it("tokenizes error values", () => {
57
+ const tokens = tokenizeFormula("IFERROR(A1/B1,#N/A)");
58
+ expect(texts(tokens)).toEqual(["IFERROR", "(", "A1", "/", "B1", ",", "#N/A", ")"]);
59
+ expect(types(tokens)).toEqual([
60
+ "function", "paren", "reference", "operator", "reference",
61
+ "separator", "error", "paren",
62
+ ]);
63
+ });
64
+
65
+ it("preserves whitespace", () => {
66
+ const tokens = tokenizeFormula("A1 + B1");
67
+ expect(texts(tokens)).toEqual(["A1", " ", "+", " ", "B1"]);
68
+ expect(types(tokens)).toEqual(["reference", "plain", "operator", "plain", "reference"]);
69
+ });
70
+
71
+ it("handles empty formula", () => {
72
+ expect(tokenizeFormula("")).toEqual([]);
73
+ });
74
+
75
+ it("reconstructs original text", () => {
76
+ const formula = 'IF(AND(A1>=10,$B$2<>""),VLOOKUP(A1,Sheet2!A:B,2,FALSE),"N/A")';
77
+ const tokens = tokenizeFormula(formula);
78
+ const reconstructed = tokens.map((t) => t.text).join("");
79
+ expect(reconstructed).toBe(formula);
80
+ });
81
+ });
@@ -0,0 +1,139 @@
1
+ // =============================================================================
2
+ // Formula Syntax Highlighting — Tokenizer
3
+ // =============================================================================
4
+ //
5
+ // Tokenizes an Excel formula string into typed spans for syntax coloring.
6
+ // Only active when the text starts with "=".
7
+ // =============================================================================
8
+
9
+ export interface FormulaToken {
10
+ text: string;
11
+ type: "function" | "reference" | "number" | "string" | "operator" | "paren" | "separator" | "error" | "plain";
12
+ }
13
+
14
+ // Cell reference pattern: optional sheet prefix, optional $, column letters, optional $, row digits
15
+ // Matches: A1, $A$1, Sheet1!A1, 'Sheet Name'!$A$1:$B$10
16
+ const REF_PATTERN = /^(?:'[^']*'!|\w+!)?\$?[A-Z]{1,3}\$?\d{1,7}(?::\$?[A-Z]{1,3}\$?\d{1,7})?/;
17
+
18
+ // Function name: letters/digits/dots/underscores followed by (
19
+ const FUNC_PATTERN = /^[A-Z][A-Z0-9_.]*(?=\()/i;
20
+
21
+ // Number: integer or decimal, optional scientific notation
22
+ const NUM_PATTERN = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
23
+
24
+ // String literal: "..." (Excel uses "" for escaped quotes inside)
25
+ const STR_PATTERN = /^"(?:[^"]|"")*"/;
26
+
27
+ // Error values
28
+ const ERROR_PATTERN = /^#(?:REF!|NAME\?|VALUE!|DIV\/0!|NULL!|N\/A|NUM!|CALC!|SPILL!|GETTING_DATA)/;
29
+
30
+ // Operators
31
+ const OPERATORS = new Set(["+", "-", "*", "/", "^", "&", "=", "<", ">", "%"]);
32
+ const MULTI_OPS = ["<>", "<=", ">="];
33
+
34
+ /** Tokenize a formula string (without leading "=") into colored spans. */
35
+ export function tokenizeFormula(formula: string): FormulaToken[] {
36
+ const tokens: FormulaToken[] = [];
37
+ let i = 0;
38
+
39
+ while (i < formula.length) {
40
+ const rest = formula.slice(i);
41
+
42
+ // Whitespace
43
+ if (rest[0] === " " || rest[0] === "\t") {
44
+ let j = 0;
45
+ while (j < rest.length && (rest[j] === " " || rest[j] === "\t")) j++;
46
+ tokens.push({ text: rest.slice(0, j), type: "plain" });
47
+ i += j;
48
+ continue;
49
+ }
50
+
51
+ // String literal
52
+ const strMatch = rest.match(STR_PATTERN);
53
+ if (strMatch) {
54
+ tokens.push({ text: strMatch[0], type: "string" });
55
+ i += strMatch[0].length;
56
+ continue;
57
+ }
58
+
59
+ // Error values
60
+ const errMatch = rest.match(ERROR_PATTERN);
61
+ if (errMatch) {
62
+ tokens.push({ text: errMatch[0], type: "error" });
63
+ i += errMatch[0].length;
64
+ continue;
65
+ }
66
+
67
+ // Function name (must check before reference since both start with letters)
68
+ const funcMatch = rest.match(FUNC_PATTERN);
69
+ if (funcMatch) {
70
+ tokens.push({ text: funcMatch[0], type: "function" });
71
+ i += funcMatch[0].length;
72
+ continue;
73
+ }
74
+
75
+ // Cell/range reference (with optional sheet prefix)
76
+ const refMatch = rest.match(REF_PATTERN);
77
+ if (refMatch) {
78
+ tokens.push({ text: refMatch[0], type: "reference" });
79
+ i += refMatch[0].length;
80
+ continue;
81
+ }
82
+
83
+ // Number
84
+ const numMatch = rest.match(NUM_PATTERN);
85
+ if (numMatch) {
86
+ tokens.push({ text: numMatch[0], type: "number" });
87
+ i += numMatch[0].length;
88
+ continue;
89
+ }
90
+
91
+ // Multi-character operators (<>, <=, >=)
92
+ const twoChar = rest.slice(0, 2);
93
+ if (MULTI_OPS.includes(twoChar)) {
94
+ tokens.push({ text: twoChar, type: "operator" });
95
+ i += 2;
96
+ continue;
97
+ }
98
+
99
+ // Single-character operator
100
+ if (OPERATORS.has(rest[0])) {
101
+ tokens.push({ text: rest[0], type: "operator" });
102
+ i += 1;
103
+ continue;
104
+ }
105
+
106
+ // Parentheses
107
+ if (rest[0] === "(" || rest[0] === ")") {
108
+ tokens.push({ text: rest[0], type: "paren" });
109
+ i += 1;
110
+ continue;
111
+ }
112
+
113
+ // Comma / semicolon separator
114
+ if (rest[0] === "," || rest[0] === ";") {
115
+ tokens.push({ text: rest[0], type: "separator" });
116
+ i += 1;
117
+ continue;
118
+ }
119
+
120
+ // Unrecognized — consume one character as plain
121
+ tokens.push({ text: rest[0], type: "plain" });
122
+ i += 1;
123
+ }
124
+
125
+ return tokens;
126
+ }
127
+
128
+ /** Color map for token types. */
129
+ export const TOKEN_COLORS: Record<FormulaToken["type"], string> = {
130
+ function: "#0000FF", // blue
131
+ reference: "#9C27B0", // purple
132
+ number: "#098658", // green
133
+ string: "#A31515", // dark red
134
+ operator: "#383838", // dark gray
135
+ paren: "#383838", // dark gray
136
+ separator: "#383838", // dark gray
137
+ error: "#E53935", // red
138
+ plain: "#1F2937", // default text
139
+ };