@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
@@ -124,9 +124,13 @@ function evaluate(expr, ctx, session) {
124
124
  case 1 /* BoundExprKind.Literal */:
125
125
  return evaluateLiteral(expr);
126
126
  case 2 /* BoundExprKind.CellRef */:
127
- return evaluateCellRef(expr, ctx, session);
127
+ // Inlined: the wrapper function did nothing beyond forwarding.
128
+ // Cell refs are the hottest AST node in the evaluator; saving one
129
+ // call frame per reference meaningfully shortens the trace for
130
+ // workbooks with tens of thousands of cells.
131
+ return (0, values_1.rvCellRef)(expr.sheet, expr.row, expr.col);
128
132
  case 3 /* BoundExprKind.AreaRef */:
129
- return evaluateAreaRef(expr, ctx, session);
133
+ return (0, values_1.rvRef)(expr.sheet, expr.top, expr.left, expr.bottom, expr.right);
130
134
  case 4 /* BoundExprKind.ColRangeRef */:
131
135
  return evaluateColRange(expr, ctx, session);
132
136
  case 5 /* BoundExprKind.RowRangeRef */:
@@ -151,6 +155,8 @@ function evaluate(expr, ctx, session) {
151
155
  return evaluateLambdaExpr(expr, ctx);
152
156
  case 15 /* BoundExprKind.StructuredRef */:
153
157
  return evaluateStructuredRef(expr, ctx, session);
158
+ case 16 /* BoundExprKind.UnionRef */:
159
+ return evaluateUnionRef(expr, ctx, session);
154
160
  default:
155
161
  return assertNever(expr);
156
162
  }
@@ -179,15 +185,13 @@ function evaluateLiteral(expr) {
179
185
  // ============================================================================
180
186
  // Cell Reference
181
187
  // ============================================================================
182
- function evaluateCellRef(expr, ctx, session) {
183
- return (0, values_1.rvCellRef)(expr.sheet, expr.row, expr.col);
184
- }
185
188
  // ============================================================================
186
- // Area Reference ReferenceValue
189
+ // Cell / Area Reference inlined at the `evaluate` switch above.
187
190
  // ============================================================================
188
- function evaluateAreaRef(expr, ctx, session) {
189
- return (0, values_1.rvRef)(expr.sheet, expr.top, expr.left, expr.bottom, expr.right);
190
- }
191
+ // Single-arg wrappers used to exist here (`evaluateCellRef` / `evaluateAreaRef`);
192
+ // they did nothing beyond forwarding to `rvCellRef` / `rvRef`. Since the
193
+ // evaluator dispatches these on every cell in every formula, we inline
194
+ // them at the caller to shave one call frame.
191
195
  function evaluateColRange(expr, ctx, session) {
192
196
  const ws = ctx.snapshot.worksheetsByName.get(expr.sheet.toLowerCase());
193
197
  if (!ws || !ws.dimensions) {
@@ -257,23 +261,47 @@ function buildRangeArray(ctx, session, sheet, top, left, bottom, right) {
257
261
  const ws = ctx.snapshot.worksheetsByName.get(sheet.toLowerCase());
258
262
  const cells = ws?.cells;
259
263
  const wsHiddenRows = ws?.hiddenRows;
264
+ // Use the canonical sheet name for cache-key computation so that a
265
+ // mis-cased sheet identifier (e.g. `sheet1` when the workbook has
266
+ // `Sheet1`) doesn't bypass the compiled-formula / result caches. The
267
+ // map is keyed by snapshot.worksheets[].name, so any divergence would
268
+ // cause spurious re-compilation.
269
+ const canonicalSheet = ws?.name ?? sheet;
260
270
  const compiledFormulas = ctx.compiledFormulas;
261
271
  const resultCache = session.resultCache;
262
272
  // Hoist the recording guard — when not recording, skip recordAccess
263
273
  // entirely in the loop instead of paying the function-call overhead.
264
274
  const recording = session.recordingKey !== null;
265
- const rows = [];
275
+ const height = bottom - top + 1;
276
+ const width = right - left + 1;
277
+ // Missing worksheet: emit an all-BLANK rectangle without entering the
278
+ // hot path. The tokenizer and binder usually report these as #REF! at
279
+ // compile time, but `evaluateRef3D` and runtime INDIRECT can still
280
+ // synthesise refs into non-existent sheets.
281
+ if (!cells) {
282
+ const rows = new Array(height);
283
+ for (let r = 0; r < height; r++) {
284
+ rows[r] = new Array(width).fill(values_1.BLANK);
285
+ }
286
+ if (recording) {
287
+ for (let r = top; r <= bottom; r++) {
288
+ for (let c = left; c <= right; c++) {
289
+ session.recordAccess(sheet, r, c);
290
+ }
291
+ }
292
+ }
293
+ return (0, values_1.rvArrayRect)(rows, height, width, top, left);
294
+ }
295
+ const rows = new Array(height);
266
296
  // Lazily-allocated masks: only materialized once we encounter a
267
297
  // SUBTOTAL/AGGREGATE cell or a hidden row inside the range. For the
268
298
  // common case (plain data range, no hidden rows) we never touch these
269
299
  // and emit an ArrayValue without extra metadata.
270
300
  let subtotalMask;
271
301
  let hiddenRowMask;
272
- const height = bottom - top + 1;
273
- const width = right - left + 1;
274
302
  for (let r = top; r <= bottom; r++) {
275
- const row = [];
276
303
  const ri = r - top;
304
+ const row = new Array(width);
277
305
  // Record row visibility — SUBTOTAL 1xx / AGGREGATE opt 5/7 use it.
278
306
  if (wsHiddenRows?.has(r)) {
279
307
  if (!hiddenRowMask) {
@@ -282,25 +310,21 @@ function buildRangeArray(ctx, session, sheet, top, left, bottom, right) {
282
310
  hiddenRowMask[ri] = true;
283
311
  }
284
312
  for (let c = left; c <= right; c++) {
313
+ const ci = c - left;
285
314
  if (recording) {
286
- session.recordAccess(sheet, r, c);
287
- }
288
- // Missing worksheet: matches getCellValue's BLANK fallback.
289
- if (!cells) {
290
- row.push(values_1.BLANK);
291
- continue;
315
+ session.recordAccess(canonicalSheet, r, c);
292
316
  }
293
317
  const cell = cells.get((0, workbook_snapshot_1.snapshotCellKey)(r, c));
294
318
  if (!cell) {
295
319
  // No snapshot cell yet — might still be inside a live spill
296
320
  // (e.g. reading A2..A5 while A1 = SEQUENCE(5) is still being
297
321
  // materialized). See `readLiveSpill` for the lookup path.
298
- const live = readLiveSpill(sheet, r, c, session);
299
- row.push(live ? (0, values_1.topLeft)(live) : values_1.BLANK);
322
+ const live = readLiveSpill(canonicalSheet, r, c, session);
323
+ row[ci] = live ? (0, values_1.topLeft)(live) : values_1.BLANK;
300
324
  continue;
301
325
  }
302
326
  if (cell.formulaKind !== "none" && cell.formula) {
303
- const fKey = (0, workbook_snapshot_1.formulaCellKey)(sheet, r, c);
327
+ const fKey = (0, workbook_snapshot_1.formulaCellKey)(canonicalSheet, r, c);
304
328
  const compiled = compiledFormulas.get(fKey);
305
329
  // Mark SUBTOTAL/AGGREGATE output cells so an outer SUBTOTAL /
306
330
  // AGGREGATE over this range knows to skip them (Excel's
@@ -312,15 +336,15 @@ function buildRangeArray(ctx, session, sheet, top, left, bottom, right) {
312
336
  subtotalMask[i] = new Array(width).fill(false);
313
337
  }
314
338
  }
315
- subtotalMask[ri][c - left] = true;
339
+ subtotalMask[ri][ci] = true;
316
340
  }
317
341
  const cached = resultCache.get(fKey);
318
342
  if (cached !== undefined) {
319
- row.push((0, values_1.topLeft)(cached.scalar));
343
+ row[ci] = (0, values_1.topLeft)(cached.scalar);
320
344
  continue;
321
345
  }
322
346
  if (compiled) {
323
- row.push((0, values_1.topLeft)(evaluateFormula(compiled, ctx, session)));
347
+ row[ci] = (0, values_1.topLeft)(evaluateFormula(compiled, ctx, session));
324
348
  continue;
325
349
  }
326
350
  }
@@ -328,16 +352,16 @@ function buildRangeArray(ctx, session, sheet, top, left, bottom, right) {
328
352
  // that a fresh dynamic-array spill is about to overwrite. Prefer
329
353
  // the live value when a master is registered so this-pass SUM /
330
354
  // LOOKUP / etc. see the new spill immediately.
331
- const live = readLiveSpill(sheet, r, c, session);
355
+ const live = readLiveSpill(canonicalSheet, r, c, session);
332
356
  if (live) {
333
- row.push((0, values_1.topLeft)(live));
357
+ row[ci] = (0, values_1.topLeft)(live);
334
358
  continue;
335
359
  }
336
- row.push((0, values_1.topLeft)((0, values_1.fromSnapshotValue)(cell.value)));
360
+ row[ci] = (0, values_1.topLeft)((0, values_1.fromSnapshotValue)(cell.value));
337
361
  }
338
- rows.push(row);
362
+ rows[ri] = row;
339
363
  }
340
- return (0, values_1.rvArray)(rows, top, left, subtotalMask, hiddenRowMask);
364
+ return (0, values_1.rvArrayRect)(rows, height, width, top, left, subtotalMask, hiddenRowMask);
341
365
  }
342
366
  // ============================================================================
343
367
  // Dereference: Reference → concrete value
@@ -436,15 +460,25 @@ function dereferenceValue(v, ctx, session) {
436
460
  // Get Cell Value from Snapshot
437
461
  // ============================================================================
438
462
  function getCellValue(sheetName, row, col, ctx, session) {
463
+ const ws = ctx.snapshot.worksheetsByName.get(sheetName.toLowerCase());
464
+ if (!ws) {
465
+ // Record the access before returning — still a valid dependency
466
+ // edge even if the sheet is missing (it'll surface as BLANK
467
+ // downstream, but an INDIRECT-produced read should still register).
468
+ if (session.recordingKey !== null) {
469
+ session.recordAccess(sheetName, row, col);
470
+ }
471
+ return values_1.BLANK;
472
+ }
473
+ // Normalise to the canonical sheet name so downstream cache keys
474
+ // (compiled formulas, result cache, spill map) all hit the same
475
+ // entries regardless of how the caller cased the input.
476
+ const canonicalSheet = ws.name;
439
477
  // Record this access for runtime dependency tracking. Inline guard
440
478
  // avoids the function call overhead in the common case where no
441
479
  // recording is active (formulas without dynamic refs).
442
480
  if (session.recordingKey !== null) {
443
- session.recordAccess(sheetName, row, col);
444
- }
445
- const ws = ctx.snapshot.worksheetsByName.get(sheetName.toLowerCase());
446
- if (!ws) {
447
- return values_1.BLANK;
481
+ session.recordAccess(canonicalSheet, row, col);
448
482
  }
449
483
  const cellKey = (0, workbook_snapshot_1.snapshotCellKey)(row, col);
450
484
  const cell = ws.cells.get(cellKey);
@@ -454,11 +488,18 @@ function getCellValue(sheetName, row, col, ctx, session) {
454
488
  // right array element. Matters when a downstream formula like
455
489
  // `SUM(A1:A5)` runs before materialize writes the ghost cells for
456
490
  // `A1 = SEQUENCE(5)` into the snapshot.
457
- return readLiveSpill(sheetName, row, col, session) ?? values_1.BLANK;
491
+ return readLiveSpill(canonicalSheet, row, col, session) ?? values_1.BLANK;
458
492
  }
459
493
  // If this cell has a formula, evaluate it
460
494
  if (cell.formulaKind !== "none" && cell.formula) {
461
- const fKey = (0, workbook_snapshot_1.formulaCellKey)(sheetName, row, col);
495
+ // Use `ws.name` (the snapshot's canonical case) rather than the
496
+ // caller's `sheetName`, which could arrive mis-cased (e.g. the
497
+ // user wrote `sheet1!A1` but the workbook has `Sheet1`). The
498
+ // compiled-formula map is keyed by the canonical form, so a
499
+ // mis-cased key would produce a spurious cache-miss + re-compile
500
+ // per read — and worse, a different key than the one the
501
+ // write-back cycle will later upsert.
502
+ const fKey = (0, workbook_snapshot_1.formulaCellKey)(canonicalSheet, row, col);
462
503
  // Check cache — return scalar form for dependency resolution
463
504
  const cached = session.resultCache.get(fKey);
464
505
  if (cached !== undefined) {
@@ -474,7 +515,7 @@ function getCellValue(sheetName, row, col, ctx, session) {
474
515
  // (e.g. a value that exists in the snapshot from a previous calc
475
516
  // cycle but is about to be overwritten by a fresh spill). Prefer the
476
517
  // live value when a master is registered.
477
- const spill = readLiveSpill(sheetName, row, col, session);
518
+ const spill = readLiveSpill(canonicalSheet, row, col, session);
478
519
  if (spill) {
479
520
  return spill;
480
521
  }
@@ -798,45 +839,45 @@ function applyScalarBinaryOp(op, left, right) {
798
839
  return !isFinite(result) ? values_1.ERRORS.NUM : (0, values_1.rvNumber)(result);
799
840
  }
800
841
  function compareScalars(left, right, op) {
801
- // Normalize blanks to a neutral form of the opposing kind so formulas like
802
- // `"" = A1` (where A1 is blank) compare equal. Without this normalisation
803
- // Excel would route us to the cross-type tiebreak below.
804
- const l = left.kind === 0 /* RVKind.Blank */
805
- ? right.kind === 2 /* RVKind.String */
806
- ? (0, values_1.rvString)("")
807
- : right.kind === 3 /* RVKind.Boolean */
808
- ? (0, values_1.rvBoolean)(false)
809
- : (0, values_1.rvNumber)(0)
810
- : left;
811
- const r = right.kind === 0 /* RVKind.Blank */
812
- ? left.kind === 2 /* RVKind.String */
813
- ? (0, values_1.rvString)("")
814
- : left.kind === 3 /* RVKind.Boolean */
815
- ? (0, values_1.rvBoolean)(false)
816
- : (0, values_1.rvNumber)(0)
817
- : right;
818
842
  let cmp;
819
- if (l.kind === r.kind) {
820
- cmp = (0, values_1.compareScalarsSameKind)(l, r);
843
+ // Fast path — same kind, no blanks. Covers the vast majority of
844
+ // comparisons and avoids the blank-normalisation allocation dance
845
+ // below. compareScalarsSameKind handles Number/String/Boolean/Blank
846
+ // intrinsically; the NaN case only arises for Error kinds.
847
+ if (left.kind === right.kind && left.kind !== 0 /* RVKind.Blank */) {
848
+ cmp = (0, values_1.compareScalarsSameKind)(left, right);
821
849
  if (!Number.isFinite(cmp)) {
822
850
  cmp = 0;
823
851
  }
824
852
  }
825
853
  else {
826
- // Excel orders scalar kinds: Number < String < Boolean < Error/Blank.
827
- const order = (v) => {
828
- if (v.kind === 1 /* RVKind.Number */) {
829
- return 0;
830
- }
831
- if (v.kind === 2 /* RVKind.String */) {
832
- return 1;
854
+ // Normalize blanks to a neutral form of the opposing kind so formulas like
855
+ // `"" = A1` (where A1 is blank) compare equal. Without this normalisation
856
+ // Excel would route us to the cross-type tiebreak below.
857
+ const l = left.kind === 0 /* RVKind.Blank */
858
+ ? right.kind === 2 /* RVKind.String */
859
+ ? (0, values_1.rvString)("")
860
+ : right.kind === 3 /* RVKind.Boolean */
861
+ ? (0, values_1.rvBoolean)(false)
862
+ : (0, values_1.rvNumber)(0)
863
+ : left;
864
+ const r = right.kind === 0 /* RVKind.Blank */
865
+ ? left.kind === 2 /* RVKind.String */
866
+ ? (0, values_1.rvString)("")
867
+ : left.kind === 3 /* RVKind.Boolean */
868
+ ? (0, values_1.rvBoolean)(false)
869
+ : (0, values_1.rvNumber)(0)
870
+ : right;
871
+ if (l.kind === r.kind) {
872
+ cmp = (0, values_1.compareScalarsSameKind)(l, r);
873
+ if (!Number.isFinite(cmp)) {
874
+ cmp = 0;
833
875
  }
834
- if (v.kind === 3 /* RVKind.Boolean */) {
835
- return 2;
836
- }
837
- return 3;
838
- };
839
- cmp = order(l) - order(r);
876
+ }
877
+ else {
878
+ // Excel orders scalar kinds: Number < String < Boolean < Error/Blank.
879
+ cmp = scalarKindOrder(l) - scalarKindOrder(r);
880
+ }
840
881
  }
841
882
  switch (op) {
842
883
  case "=":
@@ -855,6 +896,19 @@ function compareScalars(left, right, op) {
855
896
  return false;
856
897
  }
857
898
  }
899
+ /** Kind-priority used by Excel when comparing scalars of different kinds. */
900
+ function scalarKindOrder(v) {
901
+ switch (v.kind) {
902
+ case 1 /* RVKind.Number */:
903
+ return 0;
904
+ case 2 /* RVKind.String */:
905
+ return 1;
906
+ case 3 /* RVKind.Boolean */:
907
+ return 2;
908
+ default:
909
+ return 3; // Error / Blank — callers rarely reach this branch.
910
+ }
911
+ }
858
912
  function broadcastBinaryOp(op, left, right) {
859
913
  const lArr = left.kind === 5 /* RVKind.Array */ ? left : null;
860
914
  const rArr = right.kind === 5 /* RVKind.Array */ ? right : null;
@@ -879,28 +933,37 @@ function broadcastBinaryOp(op, left, right) {
879
933
  // inner loop (outRows × outCols calls) was pure overhead.
880
934
  const lScalarFallback = lArr ? undefined : (0, values_1.topLeft)(left);
881
935
  const rScalarFallback = rArr ? undefined : (0, values_1.topLeft)(right);
882
- const rows = [];
936
+ // Hoist broadcast-flag checks outside the hot loop. The four flags are
937
+ // constant for the entire operation; JS engines usually fold these, but
938
+ // turning them into `const` locals lets the inner loop read bools rather
939
+ // than recomputing `=== 1` each cell.
940
+ const lRowBroadcast = lRows === 1;
941
+ const lColBroadcast = lCols === 1;
942
+ const rRowBroadcast = rRows === 1;
943
+ const rColBroadcast = rCols === 1;
944
+ const rows = new Array(outRows);
883
945
  for (let r = 0; r < outRows; r++) {
884
- const row = [];
946
+ // Cache the row handle once per output row — avoids `arr.rows[lR]`
947
+ // double-indexing per cell. When the left operand broadcasts along
948
+ // rows (height 1) we read the same row handle every iteration.
949
+ const lRow = lArr ? lArr.rows[lRowBroadcast ? 0 : r] : undefined;
950
+ const rRow = rArr ? rArr.rows[rRowBroadcast ? 0 : r] : undefined;
951
+ const outRow = new Array(outCols);
885
952
  for (let c = 0; c < outCols; c++) {
886
- const lR = lRows === 1 ? 0 : r;
887
- const lC = lCols === 1 ? 0 : c;
888
- const rR = rRows === 1 ? 0 : r;
889
- const rC = rCols === 1 ? 0 : c;
890
- // Array values are rectangular (normalised by rvArray) so direct
891
- // indexing is safe; the previous `?? BLANK` fallback was defensive
892
- // code that never triggered in practice but cost an optional chain
893
- // per cell in a hot loop.
894
- const lVal = lArr ? lArr.rows[lR][lC] : lScalarFallback;
895
- const rVal = rArr ? rArr.rows[rR][rC] : rScalarFallback;
896
- row.push(applyScalarBinaryOp(op, lVal, rVal));
897
- }
898
- rows.push(row);
953
+ const lVal = lRow
954
+ ? lRow[lColBroadcast ? 0 : c]
955
+ : lScalarFallback;
956
+ const rVal = rRow
957
+ ? rRow[rColBroadcast ? 0 : c]
958
+ : rScalarFallback;
959
+ outRow[c] = applyScalarBinaryOp(op, lVal, rVal);
960
+ }
961
+ rows[r] = outRow;
899
962
  }
900
963
  // Propagate origin metadata
901
964
  const originRow = lArr?.originRow ?? rArr?.originRow;
902
965
  const originCol = lArr?.originCol ?? rArr?.originCol;
903
- return (0, values_1.rvArray)(rows, originRow, originCol);
966
+ return (0, values_1.rvArrayRect)(rows, outRows, outCols, originRow, originCol);
904
967
  }
905
968
  // ============================================================================
906
969
  // Unary Operations
@@ -992,16 +1055,43 @@ function evaluateCall(expr, ctx, session) {
992
1055
  if (expr.args.length === 1 && isSimpleRefFunction(canonical)) {
993
1056
  const raw = evaluate(expr.args[0], ctx, session);
994
1057
  if (raw.kind === 6 /* RVKind.Reference */ && raw.areas.length > 0) {
995
- const area = raw.areas[0];
1058
+ // ROW / COLUMN pick the top-left coordinate of the first area
1059
+ // matches Excel's reference-position rule for multi-area refs.
1060
+ // ROWS / COLUMNS match the flattened shape that `dereferenceValue`
1061
+ // would produce: for a multi-area union we stack all areas
1062
+ // vertically, so ROWS sums every area's height and COLUMNS takes
1063
+ // the max width. This keeps `ROWS(union)` consistent with
1064
+ // `SUMPRODUCT(–(union=union))` / `SUM(union)` — all of which see
1065
+ // the stacked view — instead of silently dropping the tail areas.
1066
+ const areas = raw.areas;
996
1067
  switch (canonical) {
997
1068
  case "ROW":
998
- return (0, values_1.rvNumber)(area.top);
1069
+ return (0, values_1.rvNumber)(areas[0].top);
999
1070
  case "COLUMN":
1000
- return (0, values_1.rvNumber)(area.left);
1001
- case "ROWS":
1002
- return (0, values_1.rvNumber)(area.bottom - area.top + 1);
1003
- case "COLUMNS":
1004
- return (0, values_1.rvNumber)(area.right - area.left + 1);
1071
+ return (0, values_1.rvNumber)(areas[0].left);
1072
+ case "ROWS": {
1073
+ if (areas.length === 1) {
1074
+ return (0, values_1.rvNumber)(areas[0].bottom - areas[0].top + 1);
1075
+ }
1076
+ let total = 0;
1077
+ for (const a of areas) {
1078
+ total += a.bottom - a.top + 1;
1079
+ }
1080
+ return (0, values_1.rvNumber)(total);
1081
+ }
1082
+ case "COLUMNS": {
1083
+ if (areas.length === 1) {
1084
+ return (0, values_1.rvNumber)(areas[0].right - areas[0].left + 1);
1085
+ }
1086
+ let maxW = 0;
1087
+ for (const a of areas) {
1088
+ const w = a.right - a.left + 1;
1089
+ if (w > maxW) {
1090
+ maxW = w;
1091
+ }
1092
+ }
1093
+ return (0, values_1.rvNumber)(maxW);
1094
+ }
1005
1095
  }
1006
1096
  }
1007
1097
  }
@@ -1014,10 +1104,66 @@ function evaluateCall(expr, ctx, session) {
1014
1104
  if (canonical === "CELL") {
1015
1105
  return evaluateCELL(expr.args, ctx, session);
1016
1106
  }
1107
+ // ── INDEX reference-aware path ──
1108
+ // Excel's INDEX takes an optional fourth `area_num` that selects
1109
+ // which member of a multi-area reference to index into, e.g.
1110
+ // `INDEX((A1:B2, D4:E5), 1, 1, 2) = D4`. We need to see the raw
1111
+ // ReferenceValue (not its flattened deref array) to support this.
1112
+ if (canonical === "INDEX") {
1113
+ const result = tryEvaluateINDEX(expr.args, ctx, session);
1114
+ if (result !== undefined) {
1115
+ return result;
1116
+ }
1117
+ }
1118
+ // ── AREAS reference-aware path ──
1119
+ // `AREAS((A1:B2, D4:E5))` should return 2, not 1. The standard
1120
+ // eager-dereference path flattens multi-area references into a single
1121
+ // stacked ArrayValue, losing the area count. Intercept here so we
1122
+ // see the raw ReferenceValue.
1123
+ if (canonical === "AREAS" && expr.args.length === 1) {
1124
+ const raw = evaluate(expr.args[0], ctx, session);
1125
+ if ((0, values_1.isError)(raw)) {
1126
+ return raw;
1127
+ }
1128
+ if (raw.kind === 6 /* RVKind.Reference */) {
1129
+ return (0, values_1.rvNumber)(raw.areas.length);
1130
+ }
1131
+ // AREAS expects a reference-producing argument. Non-references
1132
+ // (literals, scalars, arrays) are rejected with #VALUE! in Excel,
1133
+ // not silently coerced to `1`. Previously we returned 1 which hid
1134
+ // caller bugs like `AREAS(42)` that should surface as errors.
1135
+ return values_1.ERRORS.VALUE;
1136
+ }
1017
1137
  // Evaluate all arguments eagerly and dereference references
1018
1138
  const args = expr.args.map(arg => dereferenceValue(evaluate(arg, ctx, session), ctx, session));
1019
- // Look up function
1020
- const desc = (0, function_registry_1.lookupFunction)(expr.name);
1139
+ // Look up function via the canonical (prefix-stripped) name computed
1140
+ // above. Previously we passed `expr.name`, forcing `lookupFunction`
1141
+ // to re-strip the prefix on every call; reusing `canonical` skips
1142
+ // that redundant check.
1143
+ //
1144
+ // User-registered functions take precedence over the built-in
1145
+ // registry — this lets callers shadow a built-in (e.g. replace
1146
+ // `IRR` with a domain-specific variant) or register entirely new
1147
+ // names (`MYFN`). The resulting descriptor still goes through the
1148
+ // same arity check.
1149
+ const userDesc = ctx.userFunctions?.get(canonical);
1150
+ if (userDesc) {
1151
+ // Validate arity first — same rule as built-ins.
1152
+ if (args.length < userDesc.minArity || args.length > userDesc.maxArity) {
1153
+ return values_1.ERRORS.VALUE;
1154
+ }
1155
+ // User-supplied code can throw; catch at the boundary so a buggy
1156
+ // custom function surfaces as `#VALUE!` rather than tearing down
1157
+ // the whole calculation pass. Any RuntimeValue return (including
1158
+ // error values the user constructed intentionally) passes through.
1159
+ try {
1160
+ return userDesc.invoke(args);
1161
+ }
1162
+ catch {
1163
+ return values_1.ERRORS.VALUE;
1164
+ }
1165
+ }
1166
+ const desc = (0, function_registry_1.lookupFunction)(canonical);
1021
1167
  if (desc) {
1022
1168
  // Validate arity — produce #VALUE! for wrong argument count
1023
1169
  if (args.length < desc.minArity || args.length > desc.maxArity) {
@@ -1040,60 +1186,10 @@ function evaluateCall(expr, ctx, session) {
1040
1186
  }
1041
1187
  return desc.invoke(args);
1042
1188
  }
1043
- case "ISFORMULA": {
1044
- // ISFORMULA requires a reference argument. When the raw argument is
1045
- // a CellRef/AreaRef we can look up the underlying cell's formulaKind.
1046
- // Any other shape (literal, computed value, etc.) yields #N/A per
1047
- // Excel's behavior for non-reference arguments.
1048
- const raw = expr.args[0];
1049
- if (raw && raw.kind === 2 /* BoundExprKind.CellRef */) {
1050
- const ws = ctx.snapshot.worksheetsByName.get(raw.sheet.toLowerCase());
1051
- if (!ws) {
1052
- return values_1.ERRORS.REF;
1053
- }
1054
- const cell = ws.cells.get((0, workbook_snapshot_1.snapshotCellKey)(raw.row, raw.col));
1055
- return (0, values_1.rvBoolean)(cell !== undefined && cell.formulaKind !== "none");
1056
- }
1057
- if (raw && raw.kind === 3 /* BoundExprKind.AreaRef */) {
1058
- const ws = ctx.snapshot.worksheetsByName.get(raw.sheet.toLowerCase());
1059
- if (!ws) {
1060
- return values_1.ERRORS.REF;
1061
- }
1062
- // ISFORMULA on an area ref inspects the top-left cell.
1063
- const cell = ws.cells.get((0, workbook_snapshot_1.snapshotCellKey)(raw.top, raw.left));
1064
- return (0, values_1.rvBoolean)(cell !== undefined && cell.formulaKind !== "none");
1065
- }
1066
- return values_1.ERRORS.NA;
1067
- }
1068
- case "FORMULATEXT": {
1069
- // FORMULATEXT returns the formula source text at the referenced cell,
1070
- // or #N/A if the cell has no formula. Non-reference arguments also
1071
- // yield #N/A.
1072
- const raw = expr.args[0];
1073
- if (raw && raw.kind === 2 /* BoundExprKind.CellRef */) {
1074
- const ws = ctx.snapshot.worksheetsByName.get(raw.sheet.toLowerCase());
1075
- if (!ws) {
1076
- return values_1.ERRORS.REF;
1077
- }
1078
- const cell = ws.cells.get((0, workbook_snapshot_1.snapshotCellKey)(raw.row, raw.col));
1079
- if (cell && cell.formulaKind !== "none" && cell.formula !== undefined) {
1080
- return (0, values_1.rvString)(`=${cell.formula}`);
1081
- }
1082
- return values_1.ERRORS.NA;
1083
- }
1084
- if (raw && raw.kind === 3 /* BoundExprKind.AreaRef */) {
1085
- const ws = ctx.snapshot.worksheetsByName.get(raw.sheet.toLowerCase());
1086
- if (!ws) {
1087
- return values_1.ERRORS.REF;
1088
- }
1089
- const cell = ws.cells.get((0, workbook_snapshot_1.snapshotCellKey)(raw.top, raw.left));
1090
- if (cell && cell.formulaKind !== "none" && cell.formula !== undefined) {
1091
- return (0, values_1.rvString)(`=${cell.formula}`);
1092
- }
1093
- return values_1.ERRORS.NA;
1094
- }
1095
- return values_1.ERRORS.NA;
1096
- }
1189
+ case "ISFORMULA":
1190
+ return evaluateISFORMULA(expr.args, ctx, session);
1191
+ case "FORMULATEXT":
1192
+ return evaluateFORMULATEXT(expr.args, ctx, session);
1097
1193
  default:
1098
1194
  return desc.invoke(args);
1099
1195
  }
@@ -1449,7 +1545,23 @@ function evaluateOFFSET(args, ctx, session) {
1449
1545
  baseWidth = refExpr.right - refExpr.left + 1;
1450
1546
  }
1451
1547
  else {
1452
- return values_1.ERRORS.VALUE;
1548
+ // Evaluate — the base might be produced at runtime (INDIRECT, a
1549
+ // chained OFFSET, a defined name that bound to a reference, etc.).
1550
+ // Only accept single-area references; multi-area refs (3D / union)
1551
+ // are rejected by Excel too.
1552
+ const evaluated = evaluate(refExpr, ctx, session);
1553
+ if ((0, values_1.isError)(evaluated)) {
1554
+ return evaluated;
1555
+ }
1556
+ if (evaluated.kind !== 6 /* RVKind.Reference */ || evaluated.areas.length !== 1) {
1557
+ return values_1.ERRORS.VALUE;
1558
+ }
1559
+ const area = evaluated.areas[0];
1560
+ baseRow = area.top;
1561
+ baseCol = area.left;
1562
+ baseSheet = area.sheet;
1563
+ baseHeight = area.bottom - area.top + 1;
1564
+ baseWidth = area.right - area.left + 1;
1453
1565
  }
1454
1566
  const rowsVal = (0, values_1.topLeft)(evalDeref(args[1], ctx, session));
1455
1567
  const rowsNum = (0, values_1.toNumberRV)(rowsVal);
@@ -1570,19 +1682,50 @@ function evaluateMAP(args, ctx, session) {
1570
1682
  if (args.length < 2) {
1571
1683
  return values_1.ERRORS.VALUE;
1572
1684
  }
1573
- const arrVal = evalDeref(args[0], ctx, session);
1685
+ // Excel's MAP accepts N source arrays + 1 lambda; the lambda must take
1686
+ // exactly N parameters. Previously this implementation only read the
1687
+ // first array and ignored args[1..N-1], so `MAP(A1:A3, B1:B3,
1688
+ // LAMBDA(a,b, a+b))` silently behaved as `MAP(A1:A3, LAMBDA(a, a))`.
1574
1689
  const lambdaVal = evalDeref(args[args.length - 1], ctx, session);
1575
1690
  if (!(0, values_1.isLambda)(lambdaVal)) {
1576
1691
  return values_1.ERRORS.VALUE;
1577
1692
  }
1578
- if (arrVal.kind !== 5 /* RVKind.Array */) {
1579
- return invokeLambda(lambdaVal, [arrVal], ctx, session);
1693
+ const arrayArgs = [];
1694
+ for (let i = 0; i < args.length - 1; i++) {
1695
+ arrayArgs.push(evalDeref(args[i], ctx, session));
1696
+ }
1697
+ if (lambdaVal.params.length !== arrayArgs.length) {
1698
+ return values_1.ERRORS.VALUE;
1699
+ }
1700
+ // Determine shape — all arrays must agree. Scalar args broadcast to
1701
+ // the majority shape. If every arg is a scalar, invoke the lambda
1702
+ // once with them all.
1703
+ let height = 1;
1704
+ let width = 1;
1705
+ for (const a of arrayArgs) {
1706
+ if (a.kind === 5 /* RVKind.Array */) {
1707
+ height = Math.max(height, a.height);
1708
+ width = Math.max(width, a.width);
1709
+ }
1710
+ }
1711
+ const allScalar = arrayArgs.every(a => a.kind !== 5 /* RVKind.Array */);
1712
+ if (allScalar) {
1713
+ return invokeLambda(lambdaVal, arrayArgs, ctx, session);
1714
+ }
1715
+ // Shape-mismatch rejection for arrays that neither broadcast nor
1716
+ // agree: each non-scalar array must match the target shape exactly
1717
+ // (Excel does not broadcast beyond scalar).
1718
+ for (const a of arrayArgs) {
1719
+ if (a.kind === 5 /* RVKind.Array */ && (a.height !== height || a.width !== width)) {
1720
+ return values_1.ERRORS.VALUE;
1721
+ }
1580
1722
  }
1581
1723
  const rows = [];
1582
- for (const row of arrVal.rows) {
1724
+ for (let r = 0; r < height; r++) {
1583
1725
  const outRow = [];
1584
- for (const cell of row) {
1585
- outRow.push((0, values_1.topLeft)(invokeLambda(lambdaVal, [cell], ctx, session)));
1726
+ for (let c = 0; c < width; c++) {
1727
+ const callArgs = arrayArgs.map(a => a.kind === 5 /* RVKind.Array */ ? a.rows[r][c] : a);
1728
+ outRow.push((0, values_1.topLeft)(invokeLambda(lambdaVal, callArgs, ctx, session)));
1586
1729
  }
1587
1730
  rows.push(outRow);
1588
1731
  }
@@ -1598,10 +1741,20 @@ function evaluateREDUCE(args, ctx, session) {
1598
1741
  if (!(0, values_1.isLambda)(lambdaVal)) {
1599
1742
  return values_1.ERRORS.VALUE;
1600
1743
  }
1744
+ // REDUCE's reducer takes exactly 2 parameters: the accumulator and
1745
+ // the current value. A mismatched arity silently bound `acc` but
1746
+ // left `value` undefined (or silently dropped extra params).
1747
+ if (lambdaVal.params.length !== 2) {
1748
+ return values_1.ERRORS.VALUE;
1749
+ }
1601
1750
  if (arrVal.kind === 5 /* RVKind.Array */) {
1602
1751
  for (const row of arrVal.rows) {
1603
1752
  for (const cell of row) {
1604
- acc = invokeLambda(lambdaVal, [(0, values_1.topLeft)(acc), cell], ctx, session);
1753
+ // `acc` may legitimately be an array (the lambda returning one
1754
+ // is allowed — VSTACK inside the reducer, etc.). Only flatten
1755
+ // scalars when passing to the lambda; keeping the array shape
1756
+ // means subsequent iterations see the lambda's full output.
1757
+ acc = invokeLambda(lambdaVal, [acc, cell], ctx, session);
1605
1758
  }
1606
1759
  }
1607
1760
  }
@@ -1609,7 +1762,7 @@ function evaluateREDUCE(args, ctx, session) {
1609
1762
  // Scalar input: Excel treats the scalar as a 1×1 array and invokes the
1610
1763
  // reducer exactly once. Previously we returned `init` unchanged, which
1611
1764
  // silently broke `REDUCE(0, some_scalar, lambda)` callers.
1612
- acc = invokeLambda(lambdaVal, [(0, values_1.topLeft)(acc), (0, values_1.topLeft)(arrVal)], ctx, session);
1765
+ acc = invokeLambda(lambdaVal, [acc, (0, values_1.topLeft)(arrVal)], ctx, session);
1613
1766
  }
1614
1767
  return acc;
1615
1768
  }
@@ -1623,12 +1776,20 @@ function evaluateSCAN(args, ctx, session) {
1623
1776
  if (!(0, values_1.isLambda)(lambdaVal)) {
1624
1777
  return values_1.ERRORS.VALUE;
1625
1778
  }
1779
+ // SCAN's reducer takes (acc, value) — same arity rule as REDUCE.
1780
+ if (lambdaVal.params.length !== 2) {
1781
+ return values_1.ERRORS.VALUE;
1782
+ }
1626
1783
  const rows = [];
1627
1784
  if (arrVal.kind === 5 /* RVKind.Array */) {
1628
1785
  for (const row of arrVal.rows) {
1629
1786
  const outRow = [];
1630
1787
  for (const cell of row) {
1631
- acc = invokeLambda(lambdaVal, [(0, values_1.topLeft)(acc), cell], ctx, session);
1788
+ // Pass `acc` through without topLeft so the lambda sees the full
1789
+ // previous result (matches the REDUCE fix). Each output cell
1790
+ // still captures the scalar top-left of `acc` so the result
1791
+ // grid stays rectangular.
1792
+ acc = invokeLambda(lambdaVal, [acc, cell], ctx, session);
1632
1793
  outRow.push((0, values_1.topLeft)(acc));
1633
1794
  }
1634
1795
  rows.push(outRow);
@@ -1638,7 +1799,7 @@ function evaluateSCAN(args, ctx, session) {
1638
1799
  // Scalar input: emit a single-cell array containing the one accumulated
1639
1800
  // value. Previously we returned #CALC! here, which was an artefact of the
1640
1801
  // array-only implementation path.
1641
- const result = invokeLambda(lambdaVal, [(0, values_1.topLeft)(acc), (0, values_1.topLeft)(arrVal)], ctx, session);
1802
+ const result = invokeLambda(lambdaVal, [acc, (0, values_1.topLeft)(arrVal)], ctx, session);
1642
1803
  return (0, values_1.rvArray)([[(0, values_1.topLeft)(result)]]);
1643
1804
  }
1644
1805
  function evaluateMAKEARRAY(args, ctx, session) {
@@ -1657,6 +1818,14 @@ function evaluateMAKEARRAY(args, ctx, session) {
1657
1818
  if (!(0, values_1.isLambda)(lambdaVal)) {
1658
1819
  return values_1.ERRORS.VALUE;
1659
1820
  }
1821
+ // Excel's MAKEARRAY requires a 2-parameter lambda (row, col).
1822
+ // A 0 / 1 / 3+ param lambda is rejected at call time — previously we
1823
+ // invoked with 2 args regardless, which silently extended the lambda's
1824
+ // bindings past its declared parameter list (or left declared params
1825
+ // undefined).
1826
+ if (lambdaVal.params.length !== 2) {
1827
+ return values_1.ERRORS.VALUE;
1828
+ }
1660
1829
  // Truncate toward zero and reject non-positive / overflow sizes.
1661
1830
  // Without the cell-count cap the engine can silently allocate billions
1662
1831
  // of scalars before blowing the heap; matching the broadcast limit
@@ -1688,9 +1857,21 @@ function evaluateBYROW(args, ctx, session) {
1688
1857
  if (!(0, values_1.isLambda)(lambdaVal)) {
1689
1858
  return values_1.ERRORS.VALUE;
1690
1859
  }
1860
+ // BYROW requires a single-parameter lambda (the row). Mismatched arity
1861
+ // is rejected by Excel at call time — our previous impl silently left
1862
+ // extra params undefined.
1863
+ if (lambdaVal.params.length !== 1) {
1864
+ return values_1.ERRORS.VALUE;
1865
+ }
1691
1866
  if (arrVal.kind !== 5 /* RVKind.Array */) {
1692
1867
  return invokeLambda(lambdaVal, [(0, values_1.rvArray)([[(0, values_1.topLeft)(arrVal)]])], ctx, session);
1693
1868
  }
1869
+ // Empty array (height 0) → Excel's BYROW reports `#CALC!` because
1870
+ // there is nothing to iterate over. Previously we returned an empty
1871
+ // array value which downstream arithmetic could not use.
1872
+ if (arrVal.height === 0 || arrVal.width === 0) {
1873
+ return values_1.ERRORS.CALC;
1874
+ }
1694
1875
  const rows = [];
1695
1876
  for (const row of arrVal.rows) {
1696
1877
  const rowArr = (0, values_1.rvArray)([row.map(c => c)]);
@@ -1707,9 +1888,17 @@ function evaluateBYCOL(args, ctx, session) {
1707
1888
  if (!(0, values_1.isLambda)(lambdaVal)) {
1708
1889
  return values_1.ERRORS.VALUE;
1709
1890
  }
1891
+ // BYCOL requires a single-parameter lambda (the column). See BYROW.
1892
+ if (lambdaVal.params.length !== 1) {
1893
+ return values_1.ERRORS.VALUE;
1894
+ }
1710
1895
  if (arrVal.kind !== 5 /* RVKind.Array */) {
1711
1896
  return invokeLambda(lambdaVal, [(0, values_1.rvArray)([[(0, values_1.topLeft)(arrVal)]])], ctx, session);
1712
1897
  }
1898
+ // Empty array → `#CALC!` (see BYROW).
1899
+ if (arrVal.height === 0 || arrVal.width === 0) {
1900
+ return values_1.ERRORS.CALC;
1901
+ }
1713
1902
  const numCols = arrVal.width;
1714
1903
  const outRow = [];
1715
1904
  for (let c = 0; c < numCols; c++) {
@@ -1772,6 +1961,179 @@ function tryEvaluateRefFunction(name, args, ctx) {
1772
1961
  // ============================================================================
1773
1962
  // Reference-aware: ISREF
1774
1963
  // ============================================================================
1964
+ /**
1965
+ * Resolve the top-left cell targeted by an ISFORMULA / FORMULATEXT argument.
1966
+ *
1967
+ * Unlike `resolveCellRefArg` (used by CELL), these two functions must return
1968
+ * `#N/A` for non-reference arguments (arithmetic, literals, ...) rather than
1969
+ * `#VALUE!`. We support:
1970
+ * - purely syntactic reference nodes (CellRef / AreaRef / ColRangeRef /
1971
+ * RowRangeRef / Ref3D) — cheapest path, no evaluation needed
1972
+ * - runtime-produced references (INDIRECT, OFFSET) — we evaluate without
1973
+ * dereferencing and inspect the resulting ReferenceValue
1974
+ *
1975
+ * Anything else — including errors from INDIRECT("xx") — collapses to
1976
+ * `#N/A`, matching Excel's tolerant behaviour for these two functions.
1977
+ */
1978
+ function resolveFormulaRefArg(arg, ctx, session) {
1979
+ if (!arg) {
1980
+ return null;
1981
+ }
1982
+ // Syntactic reference forms — extract top-left directly, no evaluation.
1983
+ if (arg.kind === 2 /* BoundExprKind.CellRef */) {
1984
+ return { sheet: arg.sheet, row: arg.row, col: arg.col };
1985
+ }
1986
+ if (arg.kind === 3 /* BoundExprKind.AreaRef */) {
1987
+ return { sheet: arg.sheet, row: arg.top, col: arg.left };
1988
+ }
1989
+ if (arg.kind === 4 /* BoundExprKind.ColRangeRef */) {
1990
+ const ws = ctx.snapshot.worksheetsByName.get(arg.sheet.toLowerCase());
1991
+ const top = ws?.dimensions?.top ?? 1;
1992
+ return { sheet: arg.sheet, row: top, col: arg.leftCol };
1993
+ }
1994
+ if (arg.kind === 5 /* BoundExprKind.RowRangeRef */) {
1995
+ const ws = ctx.snapshot.worksheetsByName.get(arg.sheet.toLowerCase());
1996
+ const left = ws?.dimensions?.left ?? 1;
1997
+ return { sheet: arg.sheet, row: arg.topRow, col: left };
1998
+ }
1999
+ if (arg.kind === 6 /* BoundExprKind.Ref3D */) {
2000
+ const first = arg.sheets[0];
2001
+ if (first === undefined) {
2002
+ return null;
2003
+ }
2004
+ if (arg.inner.kind === 2 /* BoundExprKind.CellRef */) {
2005
+ return { sheet: first, row: arg.inner.row, col: arg.inner.col };
2006
+ }
2007
+ return { sheet: first, row: arg.inner.top, col: arg.inner.left };
2008
+ }
2009
+ // Evaluate without dereferencing — INDIRECT/OFFSET may yield a
2010
+ // ReferenceValue. Errors or non-references collapse to null (→ #N/A).
2011
+ const raw = evaluate(arg, ctx, session);
2012
+ if (raw.kind === 6 /* RVKind.Reference */ && raw.areas.length > 0) {
2013
+ const area = raw.areas[0];
2014
+ return { sheet: area.sheet, row: area.top, col: area.left };
2015
+ }
2016
+ return null;
2017
+ }
2018
+ function evaluateISFORMULA(args, ctx, session) {
2019
+ if (args.length !== 1) {
2020
+ return values_1.ERRORS.NA;
2021
+ }
2022
+ const target = resolveFormulaRefArg(args[0], ctx, session);
2023
+ if (!target) {
2024
+ return values_1.ERRORS.NA;
2025
+ }
2026
+ const ws = ctx.snapshot.worksheetsByName.get(target.sheet.toLowerCase());
2027
+ if (!ws) {
2028
+ return values_1.ERRORS.REF;
2029
+ }
2030
+ const cell = ws.cells.get((0, workbook_snapshot_1.snapshotCellKey)(target.row, target.col));
2031
+ return (0, values_1.rvBoolean)(cell !== undefined && cell.formulaKind !== "none");
2032
+ }
2033
+ function evaluateFORMULATEXT(args, ctx, session) {
2034
+ if (args.length !== 1) {
2035
+ return values_1.ERRORS.NA;
2036
+ }
2037
+ const target = resolveFormulaRefArg(args[0], ctx, session);
2038
+ if (!target) {
2039
+ return values_1.ERRORS.NA;
2040
+ }
2041
+ const ws = ctx.snapshot.worksheetsByName.get(target.sheet.toLowerCase());
2042
+ if (!ws) {
2043
+ return values_1.ERRORS.REF;
2044
+ }
2045
+ const cell = ws.cells.get((0, workbook_snapshot_1.snapshotCellKey)(target.row, target.col));
2046
+ if (cell && cell.formulaKind !== "none" && cell.formula !== undefined) {
2047
+ return (0, values_1.rvString)(`=${cell.formula}`);
2048
+ }
2049
+ return values_1.ERRORS.NA;
2050
+ }
2051
+ /**
2052
+ * INDEX reference-aware path.
2053
+ *
2054
+ * Standard INDEX takes `(array, row, col)` and returns a value or
2055
+ * sub-range. When the first argument is a multi-area UnionRef, INDEX
2056
+ * also accepts a fourth `area_num` argument to select which area to
2057
+ * index into — e.g. `INDEX((A1:B2, D4:E5), 1, 1, 2) = D4`.
2058
+ *
2059
+ * We intercept INDEX here (before the normal eager-dereference path)
2060
+ * because dereferencing a multi-area ReferenceValue flattens its
2061
+ * areas into a single stacked array, losing the per-area boundary
2062
+ * needed for `area_num`. For the common single-area case we return
2063
+ * `undefined` so the eager path runs and delegates to `fnINDEX`.
2064
+ */
2065
+ function tryEvaluateINDEX(args, ctx, session) {
2066
+ if (args.length < 2 || args.length > 4) {
2067
+ return undefined; // Fall through — eager fnINDEX reports arity errors.
2068
+ }
2069
+ // Fast path: 2- and 3-arg INDEX has no area_num, so we can skip the
2070
+ // union-aware logic entirely for the common single-array case. Only
2071
+ // INDEX with an explicit area_num (4 args) OR a multi-area first
2072
+ // operand needs the reference-aware route.
2073
+ const first = evaluate(args[0], ctx, session);
2074
+ if ((0, values_1.isError)(first)) {
2075
+ // Errors from the source expression propagate regardless of arity.
2076
+ return first;
2077
+ }
2078
+ const isMultiArea = first.kind === 6 /* RVKind.Reference */ && first.areas.length > 1;
2079
+ if (!isMultiArea && args.length < 4) {
2080
+ return undefined;
2081
+ }
2082
+ // Single-area reference with an explicit 4th arg still needs
2083
+ // area_num validation — Excel rejects `INDEX(A1:B2, 1, 1, 2)` as
2084
+ // #REF! since the source only has one area.
2085
+ if (first.kind !== 6 /* RVKind.Reference */ || first.areas.length === 0) {
2086
+ // Non-reference first arg (array literal, number, etc.) — only
2087
+ // valid when args.length < 4. Let the eager path handle it.
2088
+ if (args.length < 4) {
2089
+ return undefined;
2090
+ }
2091
+ // With an explicit area_num on a non-reference, Excel returns #REF!.
2092
+ return values_1.ERRORS.REF;
2093
+ }
2094
+ const areas = first.areas;
2095
+ // Resolve `area_num` (1-based). Omitted / Blank → area 1. Truncate
2096
+ // toward zero and bounds-check against the union's cardinality.
2097
+ let areaNum = 1;
2098
+ if (args.length === 4) {
2099
+ const rawArea = dereferenceValue(evaluate(args[3], ctx, session), ctx, session);
2100
+ if ((0, values_1.isError)(rawArea)) {
2101
+ return rawArea;
2102
+ }
2103
+ const s = (0, values_1.topLeft)(rawArea);
2104
+ // Treat a blank / missing 4th argument as the default (area 1) —
2105
+ // matches Excel's tolerance for `INDEX(ref, r, c, )` and avoids the
2106
+ // surprise where a trailing comma would silently produce #REF!
2107
+ // (blank → toNumberRV = 0 → out-of-range).
2108
+ if (s.kind !== 0 /* RVKind.Blank */) {
2109
+ const aRV = (0, values_1.toNumberRV)(s);
2110
+ if ((0, values_1.isError)(aRV)) {
2111
+ return aRV;
2112
+ }
2113
+ areaNum = Math.trunc(aRV.value);
2114
+ if (areaNum < 1 || areaNum > areas.length) {
2115
+ return values_1.ERRORS.REF;
2116
+ }
2117
+ }
2118
+ }
2119
+ const area = areas[areaNum - 1];
2120
+ // Build a single-area ArrayValue for the selected region, then
2121
+ // delegate to `fnINDEX` using the row/col args. This keeps the
2122
+ // actual indexing logic in one place.
2123
+ const selectedArr = buildRangeArray(ctx, session, area.sheet, area.top, area.left, area.bottom, area.right);
2124
+ const indexArgs = [selectedArr];
2125
+ for (let i = 1; i < Math.min(args.length, 3); i++) {
2126
+ indexArgs.push(dereferenceValue(evaluate(args[i], ctx, session), ctx, session));
2127
+ }
2128
+ // Delegate to the registered INDEX implementation — keeps the actual
2129
+ // indexing logic (fractional truncation, single-col collapse, row=0
2130
+ // / col=0 semantics) in one place rather than reimplementing here.
2131
+ const indexFn = (0, function_registry_1.lookupFunction)("INDEX");
2132
+ if (!indexFn) {
2133
+ return values_1.ERRORS.VALUE;
2134
+ }
2135
+ return indexFn.invoke(indexArgs);
2136
+ }
1775
2137
  /**
1776
2138
  * ISREF(value) → TRUE if `value` is a reference; FALSE otherwise.
1777
2139
  *
@@ -1915,7 +2277,23 @@ function evaluateCELL(args, ctx, session) {
1915
2277
  }
1916
2278
  switch (info) {
1917
2279
  case "address": {
1918
- return (0, values_1.rvString)(`$${colNumberToLetter(target.col)}$${target.row}`);
2280
+ // Excel qualifies the address with the sheet name when the target
2281
+ // sheet differs from the formula-cell's own sheet (e.g.
2282
+ // `CELL("address", Sheet2!A1)` → `"Sheet2!$A$1"`). Same-sheet
2283
+ // refs stay unqualified. Without the qualifier, callers that
2284
+ // parse the result (INDIRECT(CELL("address", ref))) lose the
2285
+ // sheet context and misread remote cells.
2286
+ const addr = `$${colNumberToLetter(target.col)}$${target.row}`;
2287
+ const sameSheet = ctx.currentSheet.toLowerCase() === target.sheet.toLowerCase();
2288
+ if (sameSheet) {
2289
+ return (0, values_1.rvString)(addr);
2290
+ }
2291
+ // Quote sheet names that need it (spaces, special chars, starts
2292
+ // with digit). The same rule the tokenizer uses when parsing
2293
+ // quoted sheet refs on the way in.
2294
+ const needsQuote = !/^[A-Za-z_][A-Za-z0-9_]*$/.test(target.sheet);
2295
+ const sheetPrefix = needsQuote ? `'${target.sheet.replace(/'/g, "''")}'` : target.sheet;
2296
+ return (0, values_1.rvString)(`${sheetPrefix}!${addr}`);
1919
2297
  }
1920
2298
  case "row":
1921
2299
  return (0, values_1.rvNumber)(target.row);
@@ -1964,6 +2342,16 @@ function evaluateNameExpr(expr, ctx, session) {
1964
2342
  const rangeStr = dn.ranges[0];
1965
2343
  const parsed = (0, address_utils_1.parseDefinedNameRange)(rangeStr);
1966
2344
  if (parsed) {
2345
+ // Validate against Excel's sheet coordinate limits before we pass
2346
+ // the values to `getCellValue` / `buildRangeArray`. An invalid
2347
+ // defined-name string (e.g. a range exceeding column XFD) should
2348
+ // surface as #REF! rather than silently reading BLANK cells.
2349
+ if (parsed.startRow < 1 ||
2350
+ parsed.endRow > 1048576 ||
2351
+ parsed.startCol < 1 ||
2352
+ parsed.endCol > 16384) {
2353
+ return values_1.ERRORS.REF;
2354
+ }
1967
2355
  if (parsed.startRow === parsed.endRow && parsed.startCol === parsed.endCol) {
1968
2356
  return getCellValue(parsed.sheet, parsed.startRow, parsed.startCol, ctx, session);
1969
2357
  }
@@ -2024,6 +2412,38 @@ function evaluateLambdaExpr(expr, ctx) {
2024
2412
  return (0, values_1.rvLambda)([...expr.params], expr.body, ctx.localBindings ? new Map(ctx.localBindings) : undefined);
2025
2413
  }
2026
2414
  // ============================================================================
2415
+ // Union Reference `(A1:B2, D4:E5)` — yields a multi-area ReferenceValue
2416
+ // ============================================================================
2417
+ /**
2418
+ * Evaluate a reference union syntactically formed by `(area1, area2, …)`.
2419
+ *
2420
+ * Each member must resolve to a reference-producing value. The resulting
2421
+ * `ReferenceValue` carries every area in order so that `INDEX(union, r,
2422
+ * c, area_num)` can pick the right one. Any non-reference member
2423
+ * (scalar / array literal / error) short-circuits to `#VALUE!`.
2424
+ */
2425
+ function evaluateUnionRef(expr, ctx, session) {
2426
+ const areas = [];
2427
+ for (const member of expr.areas) {
2428
+ const val = evaluate(member, ctx, session);
2429
+ if ((0, values_1.isError)(val)) {
2430
+ return val;
2431
+ }
2432
+ if (val.kind !== 6 /* RVKind.Reference */ || val.areas.length === 0) {
2433
+ // Excel rejects non-reference members of a union outright —
2434
+ // `(A1, "text")` is `#VALUE!`, not a silent coerce to 1-cell.
2435
+ return values_1.ERRORS.VALUE;
2436
+ }
2437
+ for (const a of val.areas) {
2438
+ areas.push(a);
2439
+ }
2440
+ }
2441
+ if (areas.length === 0) {
2442
+ return values_1.ERRORS.VALUE;
2443
+ }
2444
+ return { kind: 6 /* RVKind.Reference */, areas };
2445
+ }
2446
+ // ============================================================================
2027
2447
  // Structured Reference (runtime resolution)
2028
2448
  // ============================================================================
2029
2449
  function evaluateStructuredRef(expr, ctx, session) {