@cj-tech-master/excelts 9.3.1 → 9.4.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 (109) hide show
  1. package/dist/browser/index.d.ts +1 -0
  2. package/dist/browser/index.js +2 -0
  3. package/dist/browser/modules/excel/cell.d.ts +18 -0
  4. package/dist/browser/modules/excel/cell.js +21 -0
  5. package/dist/browser/modules/excel/utils/cell-format.js +85 -13
  6. package/dist/browser/modules/excel/workbook.browser.d.ts +57 -0
  7. package/dist/browser/modules/excel/workbook.browser.js +49 -0
  8. package/dist/browser/modules/excel/xlsx/defaultnumformats.js +3 -3
  9. package/dist/browser/modules/formula/compile/binder.js +48 -6
  10. package/dist/browser/modules/formula/compile/bound-ast.d.ts +16 -2
  11. package/dist/browser/modules/formula/compile/bound-ast.js +1 -0
  12. package/dist/browser/modules/formula/compile/compiled-formula.js +41 -8
  13. package/dist/browser/modules/formula/functions/_shared.d.ts +19 -0
  14. package/dist/browser/modules/formula/functions/_shared.js +47 -0
  15. package/dist/browser/modules/formula/functions/conditional.js +103 -22
  16. package/dist/browser/modules/formula/functions/date.js +105 -23
  17. package/dist/browser/modules/formula/functions/dynamic-array.js +173 -69
  18. package/dist/browser/modules/formula/functions/engineering.d.ts +2 -2
  19. package/dist/browser/modules/formula/functions/engineering.js +103 -151
  20. package/dist/browser/modules/formula/functions/financial.js +210 -184
  21. package/dist/browser/modules/formula/functions/lookup.js +224 -157
  22. package/dist/browser/modules/formula/functions/math.d.ts +26 -0
  23. package/dist/browser/modules/formula/functions/math.js +249 -69
  24. package/dist/browser/modules/formula/functions/statistical.js +221 -171
  25. package/dist/browser/modules/formula/functions/text.js +112 -52
  26. package/dist/browser/modules/formula/integration/calculate-formulas-impl.js +20 -1
  27. package/dist/browser/modules/formula/materialize/build-writeback-plan.js +10 -6
  28. package/dist/browser/modules/formula/materialize/types.d.ts +15 -0
  29. package/dist/browser/modules/formula/runtime/evaluator.d.ts +8 -0
  30. package/dist/browser/modules/formula/runtime/evaluator.js +582 -162
  31. package/dist/browser/modules/formula/runtime/function-registry.d.ts +5 -0
  32. package/dist/browser/modules/formula/runtime/function-registry.js +59 -13
  33. package/dist/browser/modules/formula/runtime/values.d.ts +13 -0
  34. package/dist/browser/modules/formula/runtime/values.js +20 -2
  35. package/dist/browser/modules/formula/syntax/ast.d.ts +14 -2
  36. package/dist/browser/modules/formula/syntax/ast.js +1 -0
  37. package/dist/browser/modules/formula/syntax/parser.js +29 -7
  38. package/dist/browser/modules/formula/syntax/token-types.d.ts +4 -0
  39. package/dist/browser/modules/formula/syntax/token-types.js +9 -0
  40. package/dist/browser/modules/formula/syntax/tokenizer.js +76 -19
  41. package/dist/cjs/index.js +7 -2
  42. package/dist/cjs/modules/excel/cell.js +21 -0
  43. package/dist/cjs/modules/excel/utils/cell-format.js +85 -13
  44. package/dist/cjs/modules/excel/workbook.browser.js +49 -0
  45. package/dist/cjs/modules/excel/xlsx/defaultnumformats.js +3 -3
  46. package/dist/cjs/modules/formula/compile/binder.js +48 -6
  47. package/dist/cjs/modules/formula/compile/compiled-formula.js +41 -8
  48. package/dist/cjs/modules/formula/functions/_shared.js +48 -0
  49. package/dist/cjs/modules/formula/functions/conditional.js +103 -22
  50. package/dist/cjs/modules/formula/functions/date.js +104 -22
  51. package/dist/cjs/modules/formula/functions/dynamic-array.js +173 -69
  52. package/dist/cjs/modules/formula/functions/engineering.js +109 -157
  53. package/dist/cjs/modules/formula/functions/financial.js +209 -183
  54. package/dist/cjs/modules/formula/functions/lookup.js +224 -157
  55. package/dist/cjs/modules/formula/functions/math.js +254 -70
  56. package/dist/cjs/modules/formula/functions/statistical.js +222 -172
  57. package/dist/cjs/modules/formula/functions/text.js +112 -52
  58. package/dist/cjs/modules/formula/integration/calculate-formulas-impl.js +20 -1
  59. package/dist/cjs/modules/formula/materialize/build-writeback-plan.js +10 -6
  60. package/dist/cjs/modules/formula/runtime/evaluator.js +581 -161
  61. package/dist/cjs/modules/formula/runtime/function-registry.js +57 -11
  62. package/dist/cjs/modules/formula/runtime/values.js +21 -2
  63. package/dist/cjs/modules/formula/syntax/parser.js +29 -7
  64. package/dist/cjs/modules/formula/syntax/token-types.js +9 -0
  65. package/dist/cjs/modules/formula/syntax/tokenizer.js +76 -19
  66. package/dist/esm/index.js +2 -0
  67. package/dist/esm/modules/excel/cell.js +21 -0
  68. package/dist/esm/modules/excel/utils/cell-format.js +85 -13
  69. package/dist/esm/modules/excel/workbook.browser.js +49 -0
  70. package/dist/esm/modules/excel/xlsx/defaultnumformats.js +3 -3
  71. package/dist/esm/modules/formula/compile/binder.js +48 -6
  72. package/dist/esm/modules/formula/compile/bound-ast.js +1 -0
  73. package/dist/esm/modules/formula/compile/compiled-formula.js +41 -8
  74. package/dist/esm/modules/formula/functions/_shared.js +47 -0
  75. package/dist/esm/modules/formula/functions/conditional.js +103 -22
  76. package/dist/esm/modules/formula/functions/date.js +105 -23
  77. package/dist/esm/modules/formula/functions/dynamic-array.js +173 -69
  78. package/dist/esm/modules/formula/functions/engineering.js +103 -151
  79. package/dist/esm/modules/formula/functions/financial.js +210 -184
  80. package/dist/esm/modules/formula/functions/lookup.js +224 -157
  81. package/dist/esm/modules/formula/functions/math.js +249 -69
  82. package/dist/esm/modules/formula/functions/statistical.js +221 -171
  83. package/dist/esm/modules/formula/functions/text.js +112 -52
  84. package/dist/esm/modules/formula/integration/calculate-formulas-impl.js +20 -1
  85. package/dist/esm/modules/formula/materialize/build-writeback-plan.js +10 -6
  86. package/dist/esm/modules/formula/runtime/evaluator.js +582 -162
  87. package/dist/esm/modules/formula/runtime/function-registry.js +59 -13
  88. package/dist/esm/modules/formula/runtime/values.js +20 -2
  89. package/dist/esm/modules/formula/syntax/ast.js +1 -0
  90. package/dist/esm/modules/formula/syntax/parser.js +29 -7
  91. package/dist/esm/modules/formula/syntax/token-types.js +9 -0
  92. package/dist/esm/modules/formula/syntax/tokenizer.js +76 -19
  93. package/dist/iife/excelts.iife.js +1502 -1379
  94. package/dist/iife/excelts.iife.js.map +1 -1
  95. package/dist/iife/excelts.iife.min.js +26 -26
  96. package/dist/types/index.d.ts +1 -0
  97. package/dist/types/modules/excel/cell.d.ts +18 -0
  98. package/dist/types/modules/excel/workbook.browser.d.ts +57 -0
  99. package/dist/types/modules/formula/compile/bound-ast.d.ts +16 -2
  100. package/dist/types/modules/formula/functions/_shared.d.ts +19 -0
  101. package/dist/types/modules/formula/functions/engineering.d.ts +2 -2
  102. package/dist/types/modules/formula/functions/math.d.ts +26 -0
  103. package/dist/types/modules/formula/materialize/types.d.ts +15 -0
  104. package/dist/types/modules/formula/runtime/evaluator.d.ts +8 -0
  105. package/dist/types/modules/formula/runtime/function-registry.d.ts +5 -0
  106. package/dist/types/modules/formula/runtime/values.d.ts +13 -0
  107. package/dist/types/modules/formula/syntax/ast.d.ts +14 -2
  108. package/dist/types/modules/formula/syntax/token-types.d.ts +4 -0
  109. package/package.json +1 -1
@@ -14,7 +14,7 @@ import { parse } from "../syntax/parser.js";
14
14
  import { stripFunctionPrefix } from "../syntax/token-types.js";
15
15
  import { tokenize } from "../syntax/tokenizer.js";
16
16
  import { lookupFunction } from "./function-registry.js";
17
- import { RVKind, BLANK, ERRORS, rvNumber, rvString, rvBoolean, rvError, rvArray, rvRef, rvCellRef, rvLambda, isError, isScalar, isLambda, toNumberRV, toStringRV, toBooleanRV, topLeft, scalarEquals, compareScalarsSameKind, fromSnapshotValue } from "./values.js";
17
+ import { RVKind, BLANK, ERRORS, rvNumber, rvString, rvBoolean, rvError, rvArray, rvArrayRect, rvRef, rvCellRef, rvLambda, isError, isScalar, isLambda, toNumberRV, toStringRV, toBooleanRV, topLeft, scalarEquals, compareScalarsSameKind, fromSnapshotValue } from "./values.js";
18
18
  /**
19
19
  * Per-calculation mutable state.
20
20
  */
@@ -117,9 +117,13 @@ export function evaluate(expr, ctx, session) {
117
117
  case BoundExprKind.Literal:
118
118
  return evaluateLiteral(expr);
119
119
  case BoundExprKind.CellRef:
120
- return evaluateCellRef(expr, ctx, session);
120
+ // Inlined: the wrapper function did nothing beyond forwarding.
121
+ // Cell refs are the hottest AST node in the evaluator; saving one
122
+ // call frame per reference meaningfully shortens the trace for
123
+ // workbooks with tens of thousands of cells.
124
+ return rvCellRef(expr.sheet, expr.row, expr.col);
121
125
  case BoundExprKind.AreaRef:
122
- return evaluateAreaRef(expr, ctx, session);
126
+ return rvRef(expr.sheet, expr.top, expr.left, expr.bottom, expr.right);
123
127
  case BoundExprKind.ColRangeRef:
124
128
  return evaluateColRange(expr, ctx, session);
125
129
  case BoundExprKind.RowRangeRef:
@@ -144,6 +148,8 @@ export function evaluate(expr, ctx, session) {
144
148
  return evaluateLambdaExpr(expr, ctx);
145
149
  case BoundExprKind.StructuredRef:
146
150
  return evaluateStructuredRef(expr, ctx, session);
151
+ case BoundExprKind.UnionRef:
152
+ return evaluateUnionRef(expr, ctx, session);
147
153
  default:
148
154
  return assertNever(expr);
149
155
  }
@@ -172,15 +178,13 @@ function evaluateLiteral(expr) {
172
178
  // ============================================================================
173
179
  // Cell Reference
174
180
  // ============================================================================
175
- function evaluateCellRef(expr, ctx, session) {
176
- return rvCellRef(expr.sheet, expr.row, expr.col);
177
- }
178
181
  // ============================================================================
179
- // Area Reference ReferenceValue
182
+ // Cell / Area Reference inlined at the `evaluate` switch above.
180
183
  // ============================================================================
181
- function evaluateAreaRef(expr, ctx, session) {
182
- return rvRef(expr.sheet, expr.top, expr.left, expr.bottom, expr.right);
183
- }
184
+ // Single-arg wrappers used to exist here (`evaluateCellRef` / `evaluateAreaRef`);
185
+ // they did nothing beyond forwarding to `rvCellRef` / `rvRef`. Since the
186
+ // evaluator dispatches these on every cell in every formula, we inline
187
+ // them at the caller to shave one call frame.
184
188
  function evaluateColRange(expr, ctx, session) {
185
189
  const ws = ctx.snapshot.worksheetsByName.get(expr.sheet.toLowerCase());
186
190
  if (!ws || !ws.dimensions) {
@@ -250,23 +254,47 @@ function buildRangeArray(ctx, session, sheet, top, left, bottom, right) {
250
254
  const ws = ctx.snapshot.worksheetsByName.get(sheet.toLowerCase());
251
255
  const cells = ws?.cells;
252
256
  const wsHiddenRows = ws?.hiddenRows;
257
+ // Use the canonical sheet name for cache-key computation so that a
258
+ // mis-cased sheet identifier (e.g. `sheet1` when the workbook has
259
+ // `Sheet1`) doesn't bypass the compiled-formula / result caches. The
260
+ // map is keyed by snapshot.worksheets[].name, so any divergence would
261
+ // cause spurious re-compilation.
262
+ const canonicalSheet = ws?.name ?? sheet;
253
263
  const compiledFormulas = ctx.compiledFormulas;
254
264
  const resultCache = session.resultCache;
255
265
  // Hoist the recording guard — when not recording, skip recordAccess
256
266
  // entirely in the loop instead of paying the function-call overhead.
257
267
  const recording = session.recordingKey !== null;
258
- const rows = [];
268
+ const height = bottom - top + 1;
269
+ const width = right - left + 1;
270
+ // Missing worksheet: emit an all-BLANK rectangle without entering the
271
+ // hot path. The tokenizer and binder usually report these as #REF! at
272
+ // compile time, but `evaluateRef3D` and runtime INDIRECT can still
273
+ // synthesise refs into non-existent sheets.
274
+ if (!cells) {
275
+ const rows = new Array(height);
276
+ for (let r = 0; r < height; r++) {
277
+ rows[r] = new Array(width).fill(BLANK);
278
+ }
279
+ if (recording) {
280
+ for (let r = top; r <= bottom; r++) {
281
+ for (let c = left; c <= right; c++) {
282
+ session.recordAccess(sheet, r, c);
283
+ }
284
+ }
285
+ }
286
+ return rvArrayRect(rows, height, width, top, left);
287
+ }
288
+ const rows = new Array(height);
259
289
  // Lazily-allocated masks: only materialized once we encounter a
260
290
  // SUBTOTAL/AGGREGATE cell or a hidden row inside the range. For the
261
291
  // common case (plain data range, no hidden rows) we never touch these
262
292
  // and emit an ArrayValue without extra metadata.
263
293
  let subtotalMask;
264
294
  let hiddenRowMask;
265
- const height = bottom - top + 1;
266
- const width = right - left + 1;
267
295
  for (let r = top; r <= bottom; r++) {
268
- const row = [];
269
296
  const ri = r - top;
297
+ const row = new Array(width);
270
298
  // Record row visibility — SUBTOTAL 1xx / AGGREGATE opt 5/7 use it.
271
299
  if (wsHiddenRows?.has(r)) {
272
300
  if (!hiddenRowMask) {
@@ -275,25 +303,21 @@ function buildRangeArray(ctx, session, sheet, top, left, bottom, right) {
275
303
  hiddenRowMask[ri] = true;
276
304
  }
277
305
  for (let c = left; c <= right; c++) {
306
+ const ci = c - left;
278
307
  if (recording) {
279
- session.recordAccess(sheet, r, c);
280
- }
281
- // Missing worksheet: matches getCellValue's BLANK fallback.
282
- if (!cells) {
283
- row.push(BLANK);
284
- continue;
308
+ session.recordAccess(canonicalSheet, r, c);
285
309
  }
286
310
  const cell = cells.get(snapshotCellKey(r, c));
287
311
  if (!cell) {
288
312
  // No snapshot cell yet — might still be inside a live spill
289
313
  // (e.g. reading A2..A5 while A1 = SEQUENCE(5) is still being
290
314
  // materialized). See `readLiveSpill` for the lookup path.
291
- const live = readLiveSpill(sheet, r, c, session);
292
- row.push(live ? topLeft(live) : BLANK);
315
+ const live = readLiveSpill(canonicalSheet, r, c, session);
316
+ row[ci] = live ? topLeft(live) : BLANK;
293
317
  continue;
294
318
  }
295
319
  if (cell.formulaKind !== "none" && cell.formula) {
296
- const fKey = formulaCellKey(sheet, r, c);
320
+ const fKey = formulaCellKey(canonicalSheet, r, c);
297
321
  const compiled = compiledFormulas.get(fKey);
298
322
  // Mark SUBTOTAL/AGGREGATE output cells so an outer SUBTOTAL /
299
323
  // AGGREGATE over this range knows to skip them (Excel's
@@ -305,15 +329,15 @@ function buildRangeArray(ctx, session, sheet, top, left, bottom, right) {
305
329
  subtotalMask[i] = new Array(width).fill(false);
306
330
  }
307
331
  }
308
- subtotalMask[ri][c - left] = true;
332
+ subtotalMask[ri][ci] = true;
309
333
  }
310
334
  const cached = resultCache.get(fKey);
311
335
  if (cached !== undefined) {
312
- row.push(topLeft(cached.scalar));
336
+ row[ci] = topLeft(cached.scalar);
313
337
  continue;
314
338
  }
315
339
  if (compiled) {
316
- row.push(topLeft(evaluateFormula(compiled, ctx, session)));
340
+ row[ci] = topLeft(evaluateFormula(compiled, ctx, session));
317
341
  continue;
318
342
  }
319
343
  }
@@ -321,16 +345,16 @@ function buildRangeArray(ctx, session, sheet, top, left, bottom, right) {
321
345
  // that a fresh dynamic-array spill is about to overwrite. Prefer
322
346
  // the live value when a master is registered so this-pass SUM /
323
347
  // LOOKUP / etc. see the new spill immediately.
324
- const live = readLiveSpill(sheet, r, c, session);
348
+ const live = readLiveSpill(canonicalSheet, r, c, session);
325
349
  if (live) {
326
- row.push(topLeft(live));
350
+ row[ci] = topLeft(live);
327
351
  continue;
328
352
  }
329
- row.push(topLeft(fromSnapshotValue(cell.value)));
353
+ row[ci] = topLeft(fromSnapshotValue(cell.value));
330
354
  }
331
- rows.push(row);
355
+ rows[ri] = row;
332
356
  }
333
- return rvArray(rows, top, left, subtotalMask, hiddenRowMask);
357
+ return rvArrayRect(rows, height, width, top, left, subtotalMask, hiddenRowMask);
334
358
  }
335
359
  // ============================================================================
336
360
  // Dereference: Reference → concrete value
@@ -429,15 +453,25 @@ function dereferenceValue(v, ctx, session) {
429
453
  // Get Cell Value from Snapshot
430
454
  // ============================================================================
431
455
  function getCellValue(sheetName, row, col, ctx, session) {
456
+ const ws = ctx.snapshot.worksheetsByName.get(sheetName.toLowerCase());
457
+ if (!ws) {
458
+ // Record the access before returning — still a valid dependency
459
+ // edge even if the sheet is missing (it'll surface as BLANK
460
+ // downstream, but an INDIRECT-produced read should still register).
461
+ if (session.recordingKey !== null) {
462
+ session.recordAccess(sheetName, row, col);
463
+ }
464
+ return BLANK;
465
+ }
466
+ // Normalise to the canonical sheet name so downstream cache keys
467
+ // (compiled formulas, result cache, spill map) all hit the same
468
+ // entries regardless of how the caller cased the input.
469
+ const canonicalSheet = ws.name;
432
470
  // Record this access for runtime dependency tracking. Inline guard
433
471
  // avoids the function call overhead in the common case where no
434
472
  // recording is active (formulas without dynamic refs).
435
473
  if (session.recordingKey !== null) {
436
- session.recordAccess(sheetName, row, col);
437
- }
438
- const ws = ctx.snapshot.worksheetsByName.get(sheetName.toLowerCase());
439
- if (!ws) {
440
- return BLANK;
474
+ session.recordAccess(canonicalSheet, row, col);
441
475
  }
442
476
  const cellKey = snapshotCellKey(row, col);
443
477
  const cell = ws.cells.get(cellKey);
@@ -447,11 +481,18 @@ function getCellValue(sheetName, row, col, ctx, session) {
447
481
  // right array element. Matters when a downstream formula like
448
482
  // `SUM(A1:A5)` runs before materialize writes the ghost cells for
449
483
  // `A1 = SEQUENCE(5)` into the snapshot.
450
- return readLiveSpill(sheetName, row, col, session) ?? BLANK;
484
+ return readLiveSpill(canonicalSheet, row, col, session) ?? BLANK;
451
485
  }
452
486
  // If this cell has a formula, evaluate it
453
487
  if (cell.formulaKind !== "none" && cell.formula) {
454
- const fKey = formulaCellKey(sheetName, row, col);
488
+ // Use `ws.name` (the snapshot's canonical case) rather than the
489
+ // caller's `sheetName`, which could arrive mis-cased (e.g. the
490
+ // user wrote `sheet1!A1` but the workbook has `Sheet1`). The
491
+ // compiled-formula map is keyed by the canonical form, so a
492
+ // mis-cased key would produce a spurious cache-miss + re-compile
493
+ // per read — and worse, a different key than the one the
494
+ // write-back cycle will later upsert.
495
+ const fKey = formulaCellKey(canonicalSheet, row, col);
455
496
  // Check cache — return scalar form for dependency resolution
456
497
  const cached = session.resultCache.get(fKey);
457
498
  if (cached !== undefined) {
@@ -467,7 +508,7 @@ function getCellValue(sheetName, row, col, ctx, session) {
467
508
  // (e.g. a value that exists in the snapshot from a previous calc
468
509
  // cycle but is about to be overwritten by a fresh spill). Prefer the
469
510
  // live value when a master is registered.
470
- const spill = readLiveSpill(sheetName, row, col, session);
511
+ const spill = readLiveSpill(canonicalSheet, row, col, session);
471
512
  if (spill) {
472
513
  return spill;
473
514
  }
@@ -791,45 +832,45 @@ function applyScalarBinaryOp(op, left, right) {
791
832
  return !isFinite(result) ? ERRORS.NUM : rvNumber(result);
792
833
  }
793
834
  function compareScalars(left, right, op) {
794
- // Normalize blanks to a neutral form of the opposing kind so formulas like
795
- // `"" = A1` (where A1 is blank) compare equal. Without this normalisation
796
- // Excel would route us to the cross-type tiebreak below.
797
- const l = left.kind === RVKind.Blank
798
- ? right.kind === RVKind.String
799
- ? rvString("")
800
- : right.kind === RVKind.Boolean
801
- ? rvBoolean(false)
802
- : rvNumber(0)
803
- : left;
804
- const r = right.kind === RVKind.Blank
805
- ? left.kind === RVKind.String
806
- ? rvString("")
807
- : left.kind === RVKind.Boolean
808
- ? rvBoolean(false)
809
- : rvNumber(0)
810
- : right;
811
835
  let cmp;
812
- if (l.kind === r.kind) {
813
- cmp = compareScalarsSameKind(l, r);
836
+ // Fast path — same kind, no blanks. Covers the vast majority of
837
+ // comparisons and avoids the blank-normalisation allocation dance
838
+ // below. compareScalarsSameKind handles Number/String/Boolean/Blank
839
+ // intrinsically; the NaN case only arises for Error kinds.
840
+ if (left.kind === right.kind && left.kind !== RVKind.Blank) {
841
+ cmp = compareScalarsSameKind(left, right);
814
842
  if (!Number.isFinite(cmp)) {
815
843
  cmp = 0;
816
844
  }
817
845
  }
818
846
  else {
819
- // Excel orders scalar kinds: Number < String < Boolean < Error/Blank.
820
- const order = (v) => {
821
- if (v.kind === RVKind.Number) {
822
- return 0;
823
- }
824
- if (v.kind === RVKind.String) {
825
- return 1;
847
+ // Normalize blanks to a neutral form of the opposing kind so formulas like
848
+ // `"" = A1` (where A1 is blank) compare equal. Without this normalisation
849
+ // Excel would route us to the cross-type tiebreak below.
850
+ const l = left.kind === RVKind.Blank
851
+ ? right.kind === RVKind.String
852
+ ? rvString("")
853
+ : right.kind === RVKind.Boolean
854
+ ? rvBoolean(false)
855
+ : rvNumber(0)
856
+ : left;
857
+ const r = right.kind === RVKind.Blank
858
+ ? left.kind === RVKind.String
859
+ ? rvString("")
860
+ : left.kind === RVKind.Boolean
861
+ ? rvBoolean(false)
862
+ : rvNumber(0)
863
+ : right;
864
+ if (l.kind === r.kind) {
865
+ cmp = compareScalarsSameKind(l, r);
866
+ if (!Number.isFinite(cmp)) {
867
+ cmp = 0;
826
868
  }
827
- if (v.kind === RVKind.Boolean) {
828
- return 2;
829
- }
830
- return 3;
831
- };
832
- cmp = order(l) - order(r);
869
+ }
870
+ else {
871
+ // Excel orders scalar kinds: Number < String < Boolean < Error/Blank.
872
+ cmp = scalarKindOrder(l) - scalarKindOrder(r);
873
+ }
833
874
  }
834
875
  switch (op) {
835
876
  case "=":
@@ -848,6 +889,19 @@ function compareScalars(left, right, op) {
848
889
  return false;
849
890
  }
850
891
  }
892
+ /** Kind-priority used by Excel when comparing scalars of different kinds. */
893
+ function scalarKindOrder(v) {
894
+ switch (v.kind) {
895
+ case RVKind.Number:
896
+ return 0;
897
+ case RVKind.String:
898
+ return 1;
899
+ case RVKind.Boolean:
900
+ return 2;
901
+ default:
902
+ return 3; // Error / Blank — callers rarely reach this branch.
903
+ }
904
+ }
851
905
  function broadcastBinaryOp(op, left, right) {
852
906
  const lArr = left.kind === RVKind.Array ? left : null;
853
907
  const rArr = right.kind === RVKind.Array ? right : null;
@@ -872,28 +926,37 @@ function broadcastBinaryOp(op, left, right) {
872
926
  // inner loop (outRows × outCols calls) was pure overhead.
873
927
  const lScalarFallback = lArr ? undefined : topLeft(left);
874
928
  const rScalarFallback = rArr ? undefined : topLeft(right);
875
- const rows = [];
929
+ // Hoist broadcast-flag checks outside the hot loop. The four flags are
930
+ // constant for the entire operation; JS engines usually fold these, but
931
+ // turning them into `const` locals lets the inner loop read bools rather
932
+ // than recomputing `=== 1` each cell.
933
+ const lRowBroadcast = lRows === 1;
934
+ const lColBroadcast = lCols === 1;
935
+ const rRowBroadcast = rRows === 1;
936
+ const rColBroadcast = rCols === 1;
937
+ const rows = new Array(outRows);
876
938
  for (let r = 0; r < outRows; r++) {
877
- const row = [];
939
+ // Cache the row handle once per output row — avoids `arr.rows[lR]`
940
+ // double-indexing per cell. When the left operand broadcasts along
941
+ // rows (height 1) we read the same row handle every iteration.
942
+ const lRow = lArr ? lArr.rows[lRowBroadcast ? 0 : r] : undefined;
943
+ const rRow = rArr ? rArr.rows[rRowBroadcast ? 0 : r] : undefined;
944
+ const outRow = new Array(outCols);
878
945
  for (let c = 0; c < outCols; c++) {
879
- const lR = lRows === 1 ? 0 : r;
880
- const lC = lCols === 1 ? 0 : c;
881
- const rR = rRows === 1 ? 0 : r;
882
- const rC = rCols === 1 ? 0 : c;
883
- // Array values are rectangular (normalised by rvArray) so direct
884
- // indexing is safe; the previous `?? BLANK` fallback was defensive
885
- // code that never triggered in practice but cost an optional chain
886
- // per cell in a hot loop.
887
- const lVal = lArr ? lArr.rows[lR][lC] : lScalarFallback;
888
- const rVal = rArr ? rArr.rows[rR][rC] : rScalarFallback;
889
- row.push(applyScalarBinaryOp(op, lVal, rVal));
890
- }
891
- rows.push(row);
946
+ const lVal = lRow
947
+ ? lRow[lColBroadcast ? 0 : c]
948
+ : lScalarFallback;
949
+ const rVal = rRow
950
+ ? rRow[rColBroadcast ? 0 : c]
951
+ : rScalarFallback;
952
+ outRow[c] = applyScalarBinaryOp(op, lVal, rVal);
953
+ }
954
+ rows[r] = outRow;
892
955
  }
893
956
  // Propagate origin metadata
894
957
  const originRow = lArr?.originRow ?? rArr?.originRow;
895
958
  const originCol = lArr?.originCol ?? rArr?.originCol;
896
- return rvArray(rows, originRow, originCol);
959
+ return rvArrayRect(rows, outRows, outCols, originRow, originCol);
897
960
  }
898
961
  // ============================================================================
899
962
  // Unary Operations
@@ -985,16 +1048,43 @@ function evaluateCall(expr, ctx, session) {
985
1048
  if (expr.args.length === 1 && isSimpleRefFunction(canonical)) {
986
1049
  const raw = evaluate(expr.args[0], ctx, session);
987
1050
  if (raw.kind === RVKind.Reference && raw.areas.length > 0) {
988
- const area = raw.areas[0];
1051
+ // ROW / COLUMN pick the top-left coordinate of the first area
1052
+ // matches Excel's reference-position rule for multi-area refs.
1053
+ // ROWS / COLUMNS match the flattened shape that `dereferenceValue`
1054
+ // would produce: for a multi-area union we stack all areas
1055
+ // vertically, so ROWS sums every area's height and COLUMNS takes
1056
+ // the max width. This keeps `ROWS(union)` consistent with
1057
+ // `SUMPRODUCT(–(union=union))` / `SUM(union)` — all of which see
1058
+ // the stacked view — instead of silently dropping the tail areas.
1059
+ const areas = raw.areas;
989
1060
  switch (canonical) {
990
1061
  case "ROW":
991
- return rvNumber(area.top);
1062
+ return rvNumber(areas[0].top);
992
1063
  case "COLUMN":
993
- return rvNumber(area.left);
994
- case "ROWS":
995
- return rvNumber(area.bottom - area.top + 1);
996
- case "COLUMNS":
997
- return rvNumber(area.right - area.left + 1);
1064
+ return rvNumber(areas[0].left);
1065
+ case "ROWS": {
1066
+ if (areas.length === 1) {
1067
+ return rvNumber(areas[0].bottom - areas[0].top + 1);
1068
+ }
1069
+ let total = 0;
1070
+ for (const a of areas) {
1071
+ total += a.bottom - a.top + 1;
1072
+ }
1073
+ return rvNumber(total);
1074
+ }
1075
+ case "COLUMNS": {
1076
+ if (areas.length === 1) {
1077
+ return rvNumber(areas[0].right - areas[0].left + 1);
1078
+ }
1079
+ let maxW = 0;
1080
+ for (const a of areas) {
1081
+ const w = a.right - a.left + 1;
1082
+ if (w > maxW) {
1083
+ maxW = w;
1084
+ }
1085
+ }
1086
+ return rvNumber(maxW);
1087
+ }
998
1088
  }
999
1089
  }
1000
1090
  }
@@ -1007,10 +1097,66 @@ function evaluateCall(expr, ctx, session) {
1007
1097
  if (canonical === "CELL") {
1008
1098
  return evaluateCELL(expr.args, ctx, session);
1009
1099
  }
1100
+ // ── INDEX reference-aware path ──
1101
+ // Excel's INDEX takes an optional fourth `area_num` that selects
1102
+ // which member of a multi-area reference to index into, e.g.
1103
+ // `INDEX((A1:B2, D4:E5), 1, 1, 2) = D4`. We need to see the raw
1104
+ // ReferenceValue (not its flattened deref array) to support this.
1105
+ if (canonical === "INDEX") {
1106
+ const result = tryEvaluateINDEX(expr.args, ctx, session);
1107
+ if (result !== undefined) {
1108
+ return result;
1109
+ }
1110
+ }
1111
+ // ── AREAS reference-aware path ──
1112
+ // `AREAS((A1:B2, D4:E5))` should return 2, not 1. The standard
1113
+ // eager-dereference path flattens multi-area references into a single
1114
+ // stacked ArrayValue, losing the area count. Intercept here so we
1115
+ // see the raw ReferenceValue.
1116
+ if (canonical === "AREAS" && expr.args.length === 1) {
1117
+ const raw = evaluate(expr.args[0], ctx, session);
1118
+ if (isError(raw)) {
1119
+ return raw;
1120
+ }
1121
+ if (raw.kind === RVKind.Reference) {
1122
+ return rvNumber(raw.areas.length);
1123
+ }
1124
+ // AREAS expects a reference-producing argument. Non-references
1125
+ // (literals, scalars, arrays) are rejected with #VALUE! in Excel,
1126
+ // not silently coerced to `1`. Previously we returned 1 which hid
1127
+ // caller bugs like `AREAS(42)` that should surface as errors.
1128
+ return ERRORS.VALUE;
1129
+ }
1010
1130
  // Evaluate all arguments eagerly and dereference references
1011
1131
  const args = expr.args.map(arg => dereferenceValue(evaluate(arg, ctx, session), ctx, session));
1012
- // Look up function
1013
- const desc = lookupFunction(expr.name);
1132
+ // Look up function via the canonical (prefix-stripped) name computed
1133
+ // above. Previously we passed `expr.name`, forcing `lookupFunction`
1134
+ // to re-strip the prefix on every call; reusing `canonical` skips
1135
+ // that redundant check.
1136
+ //
1137
+ // User-registered functions take precedence over the built-in
1138
+ // registry — this lets callers shadow a built-in (e.g. replace
1139
+ // `IRR` with a domain-specific variant) or register entirely new
1140
+ // names (`MYFN`). The resulting descriptor still goes through the
1141
+ // same arity check.
1142
+ const userDesc = ctx.userFunctions?.get(canonical);
1143
+ if (userDesc) {
1144
+ // Validate arity first — same rule as built-ins.
1145
+ if (args.length < userDesc.minArity || args.length > userDesc.maxArity) {
1146
+ return ERRORS.VALUE;
1147
+ }
1148
+ // User-supplied code can throw; catch at the boundary so a buggy
1149
+ // custom function surfaces as `#VALUE!` rather than tearing down
1150
+ // the whole calculation pass. Any RuntimeValue return (including
1151
+ // error values the user constructed intentionally) passes through.
1152
+ try {
1153
+ return userDesc.invoke(args);
1154
+ }
1155
+ catch {
1156
+ return ERRORS.VALUE;
1157
+ }
1158
+ }
1159
+ const desc = lookupFunction(canonical);
1014
1160
  if (desc) {
1015
1161
  // Validate arity — produce #VALUE! for wrong argument count
1016
1162
  if (args.length < desc.minArity || args.length > desc.maxArity) {
@@ -1033,60 +1179,10 @@ function evaluateCall(expr, ctx, session) {
1033
1179
  }
1034
1180
  return desc.invoke(args);
1035
1181
  }
1036
- case "ISFORMULA": {
1037
- // ISFORMULA requires a reference argument. When the raw argument is
1038
- // a CellRef/AreaRef we can look up the underlying cell's formulaKind.
1039
- // Any other shape (literal, computed value, etc.) yields #N/A per
1040
- // Excel's behavior for non-reference arguments.
1041
- const raw = expr.args[0];
1042
- if (raw && raw.kind === BoundExprKind.CellRef) {
1043
- const ws = ctx.snapshot.worksheetsByName.get(raw.sheet.toLowerCase());
1044
- if (!ws) {
1045
- return ERRORS.REF;
1046
- }
1047
- const cell = ws.cells.get(snapshotCellKey(raw.row, raw.col));
1048
- return rvBoolean(cell !== undefined && cell.formulaKind !== "none");
1049
- }
1050
- if (raw && raw.kind === BoundExprKind.AreaRef) {
1051
- const ws = ctx.snapshot.worksheetsByName.get(raw.sheet.toLowerCase());
1052
- if (!ws) {
1053
- return ERRORS.REF;
1054
- }
1055
- // ISFORMULA on an area ref inspects the top-left cell.
1056
- const cell = ws.cells.get(snapshotCellKey(raw.top, raw.left));
1057
- return rvBoolean(cell !== undefined && cell.formulaKind !== "none");
1058
- }
1059
- return ERRORS.NA;
1060
- }
1061
- case "FORMULATEXT": {
1062
- // FORMULATEXT returns the formula source text at the referenced cell,
1063
- // or #N/A if the cell has no formula. Non-reference arguments also
1064
- // yield #N/A.
1065
- const raw = expr.args[0];
1066
- if (raw && raw.kind === BoundExprKind.CellRef) {
1067
- const ws = ctx.snapshot.worksheetsByName.get(raw.sheet.toLowerCase());
1068
- if (!ws) {
1069
- return ERRORS.REF;
1070
- }
1071
- const cell = ws.cells.get(snapshotCellKey(raw.row, raw.col));
1072
- if (cell && cell.formulaKind !== "none" && cell.formula !== undefined) {
1073
- return rvString(`=${cell.formula}`);
1074
- }
1075
- return ERRORS.NA;
1076
- }
1077
- if (raw && raw.kind === BoundExprKind.AreaRef) {
1078
- const ws = ctx.snapshot.worksheetsByName.get(raw.sheet.toLowerCase());
1079
- if (!ws) {
1080
- return ERRORS.REF;
1081
- }
1082
- const cell = ws.cells.get(snapshotCellKey(raw.top, raw.left));
1083
- if (cell && cell.formulaKind !== "none" && cell.formula !== undefined) {
1084
- return rvString(`=${cell.formula}`);
1085
- }
1086
- return ERRORS.NA;
1087
- }
1088
- return ERRORS.NA;
1089
- }
1182
+ case "ISFORMULA":
1183
+ return evaluateISFORMULA(expr.args, ctx, session);
1184
+ case "FORMULATEXT":
1185
+ return evaluateFORMULATEXT(expr.args, ctx, session);
1090
1186
  default:
1091
1187
  return desc.invoke(args);
1092
1188
  }
@@ -1442,7 +1538,23 @@ function evaluateOFFSET(args, ctx, session) {
1442
1538
  baseWidth = refExpr.right - refExpr.left + 1;
1443
1539
  }
1444
1540
  else {
1445
- return ERRORS.VALUE;
1541
+ // Evaluate — the base might be produced at runtime (INDIRECT, a
1542
+ // chained OFFSET, a defined name that bound to a reference, etc.).
1543
+ // Only accept single-area references; multi-area refs (3D / union)
1544
+ // are rejected by Excel too.
1545
+ const evaluated = evaluate(refExpr, ctx, session);
1546
+ if (isError(evaluated)) {
1547
+ return evaluated;
1548
+ }
1549
+ if (evaluated.kind !== RVKind.Reference || evaluated.areas.length !== 1) {
1550
+ return ERRORS.VALUE;
1551
+ }
1552
+ const area = evaluated.areas[0];
1553
+ baseRow = area.top;
1554
+ baseCol = area.left;
1555
+ baseSheet = area.sheet;
1556
+ baseHeight = area.bottom - area.top + 1;
1557
+ baseWidth = area.right - area.left + 1;
1446
1558
  }
1447
1559
  const rowsVal = topLeft(evalDeref(args[1], ctx, session));
1448
1560
  const rowsNum = toNumberRV(rowsVal);
@@ -1563,19 +1675,50 @@ function evaluateMAP(args, ctx, session) {
1563
1675
  if (args.length < 2) {
1564
1676
  return ERRORS.VALUE;
1565
1677
  }
1566
- const arrVal = evalDeref(args[0], ctx, session);
1678
+ // Excel's MAP accepts N source arrays + 1 lambda; the lambda must take
1679
+ // exactly N parameters. Previously this implementation only read the
1680
+ // first array and ignored args[1..N-1], so `MAP(A1:A3, B1:B3,
1681
+ // LAMBDA(a,b, a+b))` silently behaved as `MAP(A1:A3, LAMBDA(a, a))`.
1567
1682
  const lambdaVal = evalDeref(args[args.length - 1], ctx, session);
1568
1683
  if (!isLambda(lambdaVal)) {
1569
1684
  return ERRORS.VALUE;
1570
1685
  }
1571
- if (arrVal.kind !== RVKind.Array) {
1572
- return invokeLambda(lambdaVal, [arrVal], ctx, session);
1686
+ const arrayArgs = [];
1687
+ for (let i = 0; i < args.length - 1; i++) {
1688
+ arrayArgs.push(evalDeref(args[i], ctx, session));
1689
+ }
1690
+ if (lambdaVal.params.length !== arrayArgs.length) {
1691
+ return ERRORS.VALUE;
1692
+ }
1693
+ // Determine shape — all arrays must agree. Scalar args broadcast to
1694
+ // the majority shape. If every arg is a scalar, invoke the lambda
1695
+ // once with them all.
1696
+ let height = 1;
1697
+ let width = 1;
1698
+ for (const a of arrayArgs) {
1699
+ if (a.kind === RVKind.Array) {
1700
+ height = Math.max(height, a.height);
1701
+ width = Math.max(width, a.width);
1702
+ }
1703
+ }
1704
+ const allScalar = arrayArgs.every(a => a.kind !== RVKind.Array);
1705
+ if (allScalar) {
1706
+ return invokeLambda(lambdaVal, arrayArgs, ctx, session);
1707
+ }
1708
+ // Shape-mismatch rejection for arrays that neither broadcast nor
1709
+ // agree: each non-scalar array must match the target shape exactly
1710
+ // (Excel does not broadcast beyond scalar).
1711
+ for (const a of arrayArgs) {
1712
+ if (a.kind === RVKind.Array && (a.height !== height || a.width !== width)) {
1713
+ return ERRORS.VALUE;
1714
+ }
1573
1715
  }
1574
1716
  const rows = [];
1575
- for (const row of arrVal.rows) {
1717
+ for (let r = 0; r < height; r++) {
1576
1718
  const outRow = [];
1577
- for (const cell of row) {
1578
- outRow.push(topLeft(invokeLambda(lambdaVal, [cell], ctx, session)));
1719
+ for (let c = 0; c < width; c++) {
1720
+ const callArgs = arrayArgs.map(a => a.kind === RVKind.Array ? a.rows[r][c] : a);
1721
+ outRow.push(topLeft(invokeLambda(lambdaVal, callArgs, ctx, session)));
1579
1722
  }
1580
1723
  rows.push(outRow);
1581
1724
  }
@@ -1591,10 +1734,20 @@ function evaluateREDUCE(args, ctx, session) {
1591
1734
  if (!isLambda(lambdaVal)) {
1592
1735
  return ERRORS.VALUE;
1593
1736
  }
1737
+ // REDUCE's reducer takes exactly 2 parameters: the accumulator and
1738
+ // the current value. A mismatched arity silently bound `acc` but
1739
+ // left `value` undefined (or silently dropped extra params).
1740
+ if (lambdaVal.params.length !== 2) {
1741
+ return ERRORS.VALUE;
1742
+ }
1594
1743
  if (arrVal.kind === RVKind.Array) {
1595
1744
  for (const row of arrVal.rows) {
1596
1745
  for (const cell of row) {
1597
- acc = invokeLambda(lambdaVal, [topLeft(acc), cell], ctx, session);
1746
+ // `acc` may legitimately be an array (the lambda returning one
1747
+ // is allowed — VSTACK inside the reducer, etc.). Only flatten
1748
+ // scalars when passing to the lambda; keeping the array shape
1749
+ // means subsequent iterations see the lambda's full output.
1750
+ acc = invokeLambda(lambdaVal, [acc, cell], ctx, session);
1598
1751
  }
1599
1752
  }
1600
1753
  }
@@ -1602,7 +1755,7 @@ function evaluateREDUCE(args, ctx, session) {
1602
1755
  // Scalar input: Excel treats the scalar as a 1×1 array and invokes the
1603
1756
  // reducer exactly once. Previously we returned `init` unchanged, which
1604
1757
  // silently broke `REDUCE(0, some_scalar, lambda)` callers.
1605
- acc = invokeLambda(lambdaVal, [topLeft(acc), topLeft(arrVal)], ctx, session);
1758
+ acc = invokeLambda(lambdaVal, [acc, topLeft(arrVal)], ctx, session);
1606
1759
  }
1607
1760
  return acc;
1608
1761
  }
@@ -1616,12 +1769,20 @@ function evaluateSCAN(args, ctx, session) {
1616
1769
  if (!isLambda(lambdaVal)) {
1617
1770
  return ERRORS.VALUE;
1618
1771
  }
1772
+ // SCAN's reducer takes (acc, value) — same arity rule as REDUCE.
1773
+ if (lambdaVal.params.length !== 2) {
1774
+ return ERRORS.VALUE;
1775
+ }
1619
1776
  const rows = [];
1620
1777
  if (arrVal.kind === RVKind.Array) {
1621
1778
  for (const row of arrVal.rows) {
1622
1779
  const outRow = [];
1623
1780
  for (const cell of row) {
1624
- acc = invokeLambda(lambdaVal, [topLeft(acc), cell], ctx, session);
1781
+ // Pass `acc` through without topLeft so the lambda sees the full
1782
+ // previous result (matches the REDUCE fix). Each output cell
1783
+ // still captures the scalar top-left of `acc` so the result
1784
+ // grid stays rectangular.
1785
+ acc = invokeLambda(lambdaVal, [acc, cell], ctx, session);
1625
1786
  outRow.push(topLeft(acc));
1626
1787
  }
1627
1788
  rows.push(outRow);
@@ -1631,7 +1792,7 @@ function evaluateSCAN(args, ctx, session) {
1631
1792
  // Scalar input: emit a single-cell array containing the one accumulated
1632
1793
  // value. Previously we returned #CALC! here, which was an artefact of the
1633
1794
  // array-only implementation path.
1634
- const result = invokeLambda(lambdaVal, [topLeft(acc), topLeft(arrVal)], ctx, session);
1795
+ const result = invokeLambda(lambdaVal, [acc, topLeft(arrVal)], ctx, session);
1635
1796
  return rvArray([[topLeft(result)]]);
1636
1797
  }
1637
1798
  function evaluateMAKEARRAY(args, ctx, session) {
@@ -1650,6 +1811,14 @@ function evaluateMAKEARRAY(args, ctx, session) {
1650
1811
  if (!isLambda(lambdaVal)) {
1651
1812
  return ERRORS.VALUE;
1652
1813
  }
1814
+ // Excel's MAKEARRAY requires a 2-parameter lambda (row, col).
1815
+ // A 0 / 1 / 3+ param lambda is rejected at call time — previously we
1816
+ // invoked with 2 args regardless, which silently extended the lambda's
1817
+ // bindings past its declared parameter list (or left declared params
1818
+ // undefined).
1819
+ if (lambdaVal.params.length !== 2) {
1820
+ return ERRORS.VALUE;
1821
+ }
1653
1822
  // Truncate toward zero and reject non-positive / overflow sizes.
1654
1823
  // Without the cell-count cap the engine can silently allocate billions
1655
1824
  // of scalars before blowing the heap; matching the broadcast limit
@@ -1681,9 +1850,21 @@ function evaluateBYROW(args, ctx, session) {
1681
1850
  if (!isLambda(lambdaVal)) {
1682
1851
  return ERRORS.VALUE;
1683
1852
  }
1853
+ // BYROW requires a single-parameter lambda (the row). Mismatched arity
1854
+ // is rejected by Excel at call time — our previous impl silently left
1855
+ // extra params undefined.
1856
+ if (lambdaVal.params.length !== 1) {
1857
+ return ERRORS.VALUE;
1858
+ }
1684
1859
  if (arrVal.kind !== RVKind.Array) {
1685
1860
  return invokeLambda(lambdaVal, [rvArray([[topLeft(arrVal)]])], ctx, session);
1686
1861
  }
1862
+ // Empty array (height 0) → Excel's BYROW reports `#CALC!` because
1863
+ // there is nothing to iterate over. Previously we returned an empty
1864
+ // array value which downstream arithmetic could not use.
1865
+ if (arrVal.height === 0 || arrVal.width === 0) {
1866
+ return ERRORS.CALC;
1867
+ }
1687
1868
  const rows = [];
1688
1869
  for (const row of arrVal.rows) {
1689
1870
  const rowArr = rvArray([row.map(c => c)]);
@@ -1700,9 +1881,17 @@ function evaluateBYCOL(args, ctx, session) {
1700
1881
  if (!isLambda(lambdaVal)) {
1701
1882
  return ERRORS.VALUE;
1702
1883
  }
1884
+ // BYCOL requires a single-parameter lambda (the column). See BYROW.
1885
+ if (lambdaVal.params.length !== 1) {
1886
+ return ERRORS.VALUE;
1887
+ }
1703
1888
  if (arrVal.kind !== RVKind.Array) {
1704
1889
  return invokeLambda(lambdaVal, [rvArray([[topLeft(arrVal)]])], ctx, session);
1705
1890
  }
1891
+ // Empty array → `#CALC!` (see BYROW).
1892
+ if (arrVal.height === 0 || arrVal.width === 0) {
1893
+ return ERRORS.CALC;
1894
+ }
1706
1895
  const numCols = arrVal.width;
1707
1896
  const outRow = [];
1708
1897
  for (let c = 0; c < numCols; c++) {
@@ -1765,6 +1954,179 @@ function tryEvaluateRefFunction(name, args, ctx) {
1765
1954
  // ============================================================================
1766
1955
  // Reference-aware: ISREF
1767
1956
  // ============================================================================
1957
+ /**
1958
+ * Resolve the top-left cell targeted by an ISFORMULA / FORMULATEXT argument.
1959
+ *
1960
+ * Unlike `resolveCellRefArg` (used by CELL), these two functions must return
1961
+ * `#N/A` for non-reference arguments (arithmetic, literals, ...) rather than
1962
+ * `#VALUE!`. We support:
1963
+ * - purely syntactic reference nodes (CellRef / AreaRef / ColRangeRef /
1964
+ * RowRangeRef / Ref3D) — cheapest path, no evaluation needed
1965
+ * - runtime-produced references (INDIRECT, OFFSET) — we evaluate without
1966
+ * dereferencing and inspect the resulting ReferenceValue
1967
+ *
1968
+ * Anything else — including errors from INDIRECT("xx") — collapses to
1969
+ * `#N/A`, matching Excel's tolerant behaviour for these two functions.
1970
+ */
1971
+ function resolveFormulaRefArg(arg, ctx, session) {
1972
+ if (!arg) {
1973
+ return null;
1974
+ }
1975
+ // Syntactic reference forms — extract top-left directly, no evaluation.
1976
+ if (arg.kind === BoundExprKind.CellRef) {
1977
+ return { sheet: arg.sheet, row: arg.row, col: arg.col };
1978
+ }
1979
+ if (arg.kind === BoundExprKind.AreaRef) {
1980
+ return { sheet: arg.sheet, row: arg.top, col: arg.left };
1981
+ }
1982
+ if (arg.kind === BoundExprKind.ColRangeRef) {
1983
+ const ws = ctx.snapshot.worksheetsByName.get(arg.sheet.toLowerCase());
1984
+ const top = ws?.dimensions?.top ?? 1;
1985
+ return { sheet: arg.sheet, row: top, col: arg.leftCol };
1986
+ }
1987
+ if (arg.kind === BoundExprKind.RowRangeRef) {
1988
+ const ws = ctx.snapshot.worksheetsByName.get(arg.sheet.toLowerCase());
1989
+ const left = ws?.dimensions?.left ?? 1;
1990
+ return { sheet: arg.sheet, row: arg.topRow, col: left };
1991
+ }
1992
+ if (arg.kind === BoundExprKind.Ref3D) {
1993
+ const first = arg.sheets[0];
1994
+ if (first === undefined) {
1995
+ return null;
1996
+ }
1997
+ if (arg.inner.kind === BoundExprKind.CellRef) {
1998
+ return { sheet: first, row: arg.inner.row, col: arg.inner.col };
1999
+ }
2000
+ return { sheet: first, row: arg.inner.top, col: arg.inner.left };
2001
+ }
2002
+ // Evaluate without dereferencing — INDIRECT/OFFSET may yield a
2003
+ // ReferenceValue. Errors or non-references collapse to null (→ #N/A).
2004
+ const raw = evaluate(arg, ctx, session);
2005
+ if (raw.kind === RVKind.Reference && raw.areas.length > 0) {
2006
+ const area = raw.areas[0];
2007
+ return { sheet: area.sheet, row: area.top, col: area.left };
2008
+ }
2009
+ return null;
2010
+ }
2011
+ function evaluateISFORMULA(args, ctx, session) {
2012
+ if (args.length !== 1) {
2013
+ return ERRORS.NA;
2014
+ }
2015
+ const target = resolveFormulaRefArg(args[0], ctx, session);
2016
+ if (!target) {
2017
+ return ERRORS.NA;
2018
+ }
2019
+ const ws = ctx.snapshot.worksheetsByName.get(target.sheet.toLowerCase());
2020
+ if (!ws) {
2021
+ return ERRORS.REF;
2022
+ }
2023
+ const cell = ws.cells.get(snapshotCellKey(target.row, target.col));
2024
+ return rvBoolean(cell !== undefined && cell.formulaKind !== "none");
2025
+ }
2026
+ function evaluateFORMULATEXT(args, ctx, session) {
2027
+ if (args.length !== 1) {
2028
+ return ERRORS.NA;
2029
+ }
2030
+ const target = resolveFormulaRefArg(args[0], ctx, session);
2031
+ if (!target) {
2032
+ return ERRORS.NA;
2033
+ }
2034
+ const ws = ctx.snapshot.worksheetsByName.get(target.sheet.toLowerCase());
2035
+ if (!ws) {
2036
+ return ERRORS.REF;
2037
+ }
2038
+ const cell = ws.cells.get(snapshotCellKey(target.row, target.col));
2039
+ if (cell && cell.formulaKind !== "none" && cell.formula !== undefined) {
2040
+ return rvString(`=${cell.formula}`);
2041
+ }
2042
+ return ERRORS.NA;
2043
+ }
2044
+ /**
2045
+ * INDEX reference-aware path.
2046
+ *
2047
+ * Standard INDEX takes `(array, row, col)` and returns a value or
2048
+ * sub-range. When the first argument is a multi-area UnionRef, INDEX
2049
+ * also accepts a fourth `area_num` argument to select which area to
2050
+ * index into — e.g. `INDEX((A1:B2, D4:E5), 1, 1, 2) = D4`.
2051
+ *
2052
+ * We intercept INDEX here (before the normal eager-dereference path)
2053
+ * because dereferencing a multi-area ReferenceValue flattens its
2054
+ * areas into a single stacked array, losing the per-area boundary
2055
+ * needed for `area_num`. For the common single-area case we return
2056
+ * `undefined` so the eager path runs and delegates to `fnINDEX`.
2057
+ */
2058
+ function tryEvaluateINDEX(args, ctx, session) {
2059
+ if (args.length < 2 || args.length > 4) {
2060
+ return undefined; // Fall through — eager fnINDEX reports arity errors.
2061
+ }
2062
+ // Fast path: 2- and 3-arg INDEX has no area_num, so we can skip the
2063
+ // union-aware logic entirely for the common single-array case. Only
2064
+ // INDEX with an explicit area_num (4 args) OR a multi-area first
2065
+ // operand needs the reference-aware route.
2066
+ const first = evaluate(args[0], ctx, session);
2067
+ if (isError(first)) {
2068
+ // Errors from the source expression propagate regardless of arity.
2069
+ return first;
2070
+ }
2071
+ const isMultiArea = first.kind === RVKind.Reference && first.areas.length > 1;
2072
+ if (!isMultiArea && args.length < 4) {
2073
+ return undefined;
2074
+ }
2075
+ // Single-area reference with an explicit 4th arg still needs
2076
+ // area_num validation — Excel rejects `INDEX(A1:B2, 1, 1, 2)` as
2077
+ // #REF! since the source only has one area.
2078
+ if (first.kind !== RVKind.Reference || first.areas.length === 0) {
2079
+ // Non-reference first arg (array literal, number, etc.) — only
2080
+ // valid when args.length < 4. Let the eager path handle it.
2081
+ if (args.length < 4) {
2082
+ return undefined;
2083
+ }
2084
+ // With an explicit area_num on a non-reference, Excel returns #REF!.
2085
+ return ERRORS.REF;
2086
+ }
2087
+ const areas = first.areas;
2088
+ // Resolve `area_num` (1-based). Omitted / Blank → area 1. Truncate
2089
+ // toward zero and bounds-check against the union's cardinality.
2090
+ let areaNum = 1;
2091
+ if (args.length === 4) {
2092
+ const rawArea = dereferenceValue(evaluate(args[3], ctx, session), ctx, session);
2093
+ if (isError(rawArea)) {
2094
+ return rawArea;
2095
+ }
2096
+ const s = topLeft(rawArea);
2097
+ // Treat a blank / missing 4th argument as the default (area 1) —
2098
+ // matches Excel's tolerance for `INDEX(ref, r, c, )` and avoids the
2099
+ // surprise where a trailing comma would silently produce #REF!
2100
+ // (blank → toNumberRV = 0 → out-of-range).
2101
+ if (s.kind !== RVKind.Blank) {
2102
+ const aRV = toNumberRV(s);
2103
+ if (isError(aRV)) {
2104
+ return aRV;
2105
+ }
2106
+ areaNum = Math.trunc(aRV.value);
2107
+ if (areaNum < 1 || areaNum > areas.length) {
2108
+ return ERRORS.REF;
2109
+ }
2110
+ }
2111
+ }
2112
+ const area = areas[areaNum - 1];
2113
+ // Build a single-area ArrayValue for the selected region, then
2114
+ // delegate to `fnINDEX` using the row/col args. This keeps the
2115
+ // actual indexing logic in one place.
2116
+ const selectedArr = buildRangeArray(ctx, session, area.sheet, area.top, area.left, area.bottom, area.right);
2117
+ const indexArgs = [selectedArr];
2118
+ for (let i = 1; i < Math.min(args.length, 3); i++) {
2119
+ indexArgs.push(dereferenceValue(evaluate(args[i], ctx, session), ctx, session));
2120
+ }
2121
+ // Delegate to the registered INDEX implementation — keeps the actual
2122
+ // indexing logic (fractional truncation, single-col collapse, row=0
2123
+ // / col=0 semantics) in one place rather than reimplementing here.
2124
+ const indexFn = lookupFunction("INDEX");
2125
+ if (!indexFn) {
2126
+ return ERRORS.VALUE;
2127
+ }
2128
+ return indexFn.invoke(indexArgs);
2129
+ }
1768
2130
  /**
1769
2131
  * ISREF(value) → TRUE if `value` is a reference; FALSE otherwise.
1770
2132
  *
@@ -1908,7 +2270,23 @@ function evaluateCELL(args, ctx, session) {
1908
2270
  }
1909
2271
  switch (info) {
1910
2272
  case "address": {
1911
- return rvString(`$${colNumberToLetter(target.col)}$${target.row}`);
2273
+ // Excel qualifies the address with the sheet name when the target
2274
+ // sheet differs from the formula-cell's own sheet (e.g.
2275
+ // `CELL("address", Sheet2!A1)` → `"Sheet2!$A$1"`). Same-sheet
2276
+ // refs stay unqualified. Without the qualifier, callers that
2277
+ // parse the result (INDIRECT(CELL("address", ref))) lose the
2278
+ // sheet context and misread remote cells.
2279
+ const addr = `$${colNumberToLetter(target.col)}$${target.row}`;
2280
+ const sameSheet = ctx.currentSheet.toLowerCase() === target.sheet.toLowerCase();
2281
+ if (sameSheet) {
2282
+ return rvString(addr);
2283
+ }
2284
+ // Quote sheet names that need it (spaces, special chars, starts
2285
+ // with digit). The same rule the tokenizer uses when parsing
2286
+ // quoted sheet refs on the way in.
2287
+ const needsQuote = !/^[A-Za-z_][A-Za-z0-9_]*$/.test(target.sheet);
2288
+ const sheetPrefix = needsQuote ? `'${target.sheet.replace(/'/g, "''")}'` : target.sheet;
2289
+ return rvString(`${sheetPrefix}!${addr}`);
1912
2290
  }
1913
2291
  case "row":
1914
2292
  return rvNumber(target.row);
@@ -1957,6 +2335,16 @@ function evaluateNameExpr(expr, ctx, session) {
1957
2335
  const rangeStr = dn.ranges[0];
1958
2336
  const parsed = parseDefinedNameRange(rangeStr);
1959
2337
  if (parsed) {
2338
+ // Validate against Excel's sheet coordinate limits before we pass
2339
+ // the values to `getCellValue` / `buildRangeArray`. An invalid
2340
+ // defined-name string (e.g. a range exceeding column XFD) should
2341
+ // surface as #REF! rather than silently reading BLANK cells.
2342
+ if (parsed.startRow < 1 ||
2343
+ parsed.endRow > 1048576 ||
2344
+ parsed.startCol < 1 ||
2345
+ parsed.endCol > 16384) {
2346
+ return ERRORS.REF;
2347
+ }
1960
2348
  if (parsed.startRow === parsed.endRow && parsed.startCol === parsed.endCol) {
1961
2349
  return getCellValue(parsed.sheet, parsed.startRow, parsed.startCol, ctx, session);
1962
2350
  }
@@ -2017,6 +2405,38 @@ function evaluateLambdaExpr(expr, ctx) {
2017
2405
  return rvLambda([...expr.params], expr.body, ctx.localBindings ? new Map(ctx.localBindings) : undefined);
2018
2406
  }
2019
2407
  // ============================================================================
2408
+ // Union Reference `(A1:B2, D4:E5)` — yields a multi-area ReferenceValue
2409
+ // ============================================================================
2410
+ /**
2411
+ * Evaluate a reference union syntactically formed by `(area1, area2, …)`.
2412
+ *
2413
+ * Each member must resolve to a reference-producing value. The resulting
2414
+ * `ReferenceValue` carries every area in order so that `INDEX(union, r,
2415
+ * c, area_num)` can pick the right one. Any non-reference member
2416
+ * (scalar / array literal / error) short-circuits to `#VALUE!`.
2417
+ */
2418
+ function evaluateUnionRef(expr, ctx, session) {
2419
+ const areas = [];
2420
+ for (const member of expr.areas) {
2421
+ const val = evaluate(member, ctx, session);
2422
+ if (isError(val)) {
2423
+ return val;
2424
+ }
2425
+ if (val.kind !== RVKind.Reference || val.areas.length === 0) {
2426
+ // Excel rejects non-reference members of a union outright —
2427
+ // `(A1, "text")` is `#VALUE!`, not a silent coerce to 1-cell.
2428
+ return ERRORS.VALUE;
2429
+ }
2430
+ for (const a of val.areas) {
2431
+ areas.push(a);
2432
+ }
2433
+ }
2434
+ if (areas.length === 0) {
2435
+ return ERRORS.VALUE;
2436
+ }
2437
+ return { kind: RVKind.Reference, areas };
2438
+ }
2439
+ // ============================================================================
2020
2440
  // Structured Reference (runtime resolution)
2021
2441
  // ============================================================================
2022
2442
  function evaluateStructuredRef(expr, ctx, session) {