[BREAKING CHANGE] Make INDEX Excel-compatible for zero and omitted indices (HF-224) - #1754
Draft
sequba wants to merge 15 commits into
Draft
[BREAKING CHANGE] Make INDEX Excel-compatible for zero and omitted indices (HF-224)#1754sequba wants to merge 15 commits into
sequba wants to merge 15 commits into
Conversation
An index of 0 now selects every row or every column of the range, so INDEX returns a whole row, a whole column or the whole range, as Excel does. An omitted third argument means "every column" instead of defaulting to 1, and a single-row range reads the only index provided as the column number. The shape of an array result has to be known before the formula is evaluated, so it is derived from the formula alone: indices written as literal numbers give an exact shape, while an index computed by a subexpression makes the result a single cell, which still lets an enclosing function consume a whole row or column. Alongside that, INDEX now truncates fractional indices toward zero instead of silently returning the top-left cell, returns #REF! instead of #NUM! for an index that exceeds the range (matching what the cell references guide already documented), and reports a negative index as "Value cannot be negative." Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
Contributor
|
Task linked: HF-224 Make INDEX function Excel-compatible |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
hyperformula-docs | 3ae0e20 | Commit Preview URL Branch Preview URL |
Aug 28 2026, 04:50 PM |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
…224)
A vectorized call evaluates the function once per element of an array argument
and throws on an array result, so `=INDEX(A1:C3, {1,0}, 1)` with the array
arithmetic mode enabled crashed the engine with "Function returning array
cannot be vectorized." INDEX now declares `vectorizationForbidden`, as every
other array-output function does: an array passed as an index argument is
resolved to a single value, exactly as it already was outside the array
arithmetic mode.
Also removes a throwaway probe spec that was committed by mistake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
Performance comparison of head (3ae0e20) vs base (419028a) |
The array path builds a row list and maps over it, which the common case — two non-zero indices — does not need. INDEX is evaluated per cell, so the single value is now read straight out of the range, as it was before this branch, and the allocations are left to the results that really are arrays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
The sign was checked after truncation, so `=INDEX(A1:C3, -0.5, 1)` truncated to zero and asked for the whole column. A negated literal is not a literal number, so no room was reserved for that array and the formula reported "Cell range not allowed." instead of the negative index it was given. The sign is now checked on the argument as supplied, which makes the diagnostic the same whether the index is written into the formula or computed, and `indexArraySize` keeps the literal untruncated so a negative one is predicted as the single error cell it turns out to be — `=INDEX(A1:C1, -1, 0)` no longer spreads the error across three cells. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
An index past the end of the range is an error, but the shape predicted for the formula was still the shape a successful whole row or column would have had, so the error filled that area: `=INDEX(A1:C1, 2, 0)` reported #REF! in three cells where Excel reports one. `indexArraySize` now predicts a single cell whenever the resolved index does not fit the range, which is the shape the failing formula actually needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
A negative index is always written as a negated literal, which parses as a unary-minus node rather than a literal number, so `staticIndexArgument` returns `undefined` for it and the size method has already given up before the sign could matter. The guard could not be reached, and coverage said so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
An unbounded range has an infinite dimension: `AbsoluteColumnRange` ends at `Number.POSITIVE_INFINITY`. `indexArraySize` passed that straight through, and `ArrayValue.resize` skips growing an array toward a non-finite size, so `=INDEX(A:A, 0, 1)` reserved an infinite column and then reported only its first cell — the rest of the column was dropped with no error, while `=SUM(INDEX(A:A, 0, 1))` summed all of it. A size that cannot be reserved is now predicted as a single cell, so the formula says its result does not fit instead of silently spilling the part that does. A whole row of a column range still spills: only the unbounded dimension is affected. An empty range crashed outright. `effectiveHeight()` is 0 for an unbounded range over an empty sheet, and a zero index then asked for every one of zero rows, which reached `SimpleRangeValue.onlyValues([])` and threw `TypeError: Cannot read properties of undefined (reading 'length')` out of `buildFromSheets`. A range with no cells now reports #REF! instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
The docs said an array result is sized whenever both indices are literal numbers. That was wrong in both directions: an omitted index is not a literal number yet works, and literal indices are not enough when the range's own size is unknown before evaluation — a named expression, an unbounded range, or most function results. `=INDEX(myNamedRange, 2)` returns #VALUE! for exactly that reason, and the docs did not say so. Also stops the catalogue's short description inviting the reader to pair a row of 0 with "whole row": a row of 0 returns the whole column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
Excel's rule that a single-row range reads its only index as a column number was keyed on the height the sheet currently uses. For an unbounded range that is the data's height, not the range's, so `=INDEX(A:C, 2)` meant "cell B1" on a sheet holding one row and "the whole second row" once a second row was filled in: the argument changed meaning because of data it does not refer to. The rule now uses the height the range is declared with, which for `A:C` is unbounded and therefore never a single row. That also matches what `indexArraySize` sees, since the declared size is all it has to work from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
…ex-excel-compatible # Conflicts: # CHANGELOG.md
The entry described the two-argument form returning a whole row, but not that the same form over a multi-column range returns #VALUE! when the row number is computed rather than written as a literal, where it used to return the value in the first column. Single-column ranges, where the two-argument form is normally used, are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
Checked against real Excel, and two behaviours here were wrong. Leaving the third argument out is not the same as passing zero. Excel requires the range to be a single row or a single column and reads the only index given as the position along it, so `=INDEX(A1:C1, 3)` is C1 and `=INDEX(A1:A3, 2)` is A2. Given several rows and several columns there is nothing for that index to mean and Excel answers #REF!, where this branch returned the whole row. An argument left empty rather than left out is a zero, so `=INDEX(A1:C1, 3, )` asks for the whole third row of a one-row range and is #REF!, where this branch read it as an omission and returned C1. All fifteen probed formulas now agree with Excel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
The INDEX rework breaks formulas that work today — a two-argument call over a range with several rows and several columns returned a value and now returns #REF! — so it needs more than a changelog line. Adds docs/guide/migration-from-3.x-to-4.0.md covering each change with a before and after table, says which formulas to look for, and gives the rewrite for the one case that needs editing. Registers the guide in the docs sidebar and links it from every INDEX entry in the changelog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
Two comments survived the change of behaviour and now contradicted the code beside them: `index()` said an omitted column argument is read as zero and that Excel returns the whole second row for `=INDEX(A1:C3, 2)`, which is what the Excel probe disproved, and `declaredHeightOf` leaned on the same reading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktc39ZHYApNC48BXJXpdZo
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1754 +/- ##
===========================================
- Coverage 97.32% 97.32% -0.01%
===========================================
Files 195 195
Lines 15739 15797 +58
Branches 3390 3413 +23
===========================================
+ Hits 15318 15374 +56
- Misses 421 423 +2
🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
HF-224. A customer reported that
INDEX(range, row_num, 0)does not return the entire row as it does in Excel. The task asks for INDEX to be made Excel-compatible as far as is possible without major architectural changes.known-limitations.mdrecorded the gap: "The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell." This pull request removes that limitation and fixes the other Excel divergences found in INDEX along the way.This is a breaking change and ships with a migration guide —
docs/guide/migration-from-3.x-to-4.0.md. Formulas that work today can change result: most importantly=INDEX(A1:C3, 2), which returned the value ofA2and now returns#REF!, because Excel requires a one-dimensional range when the column argument is left out.Verified against real Excel
The behaviour was checked formula by formula in Excel by the maintainer, using
ERROR.TYPEso that a localized Excel still gives an unambiguous answer. All fifteen probed formulas now agree. Two of them disagreed at first and the implementation was corrected:=INDEX(A1:C3, 9, 1)#REF!#REF!=INDEX(A1:C3, 1, 9)#REF!#REF!=INDEX({1,2,3;4,5,6;7,8,9}, 9, 1)#REF!#REF!(array form does not differ)=INDEX(A1:C3, -1, 1)#VALUE!#VALUE!=INDEX(A1:C3, -0.5, 1)#VALUE!#VALUE!(the sign is judged before truncation)=INDEX(A1:C3, 2.9, 1)44=INDEX(A1:C1, 3)33=INDEX(A1:C1, 3, )#REF!#REF!← corrected, previously3=INDEX(A1:C1, 4)#REF!#REF!=INDEX(A1:A3, 2)44=INDEX(A1:C3, 2, 2)55=INDEX(A1:C3, 2, 0)4, 5, 64, 5, 6=INDEX(A1:C3, 0, 2)2; 5; 82; 5; 8=INDEX(A1:C3, 0, 0)=INDEX(A1:C3, 2)#REF!#REF!← corrected, previously the whole row=SUM(INDEX(A1:C3, 0, 1))1212The rule the data supports, and what is now implemented: leaving
column_numout is not the same as passing0. Left out, Excel requires the range to be a single row or a single column and reads the only index as the position along it; given several rows and several columns there is nothing for that index to mean and the answer is#REF!. Left empty (=INDEX(A1:C1, 3, )) it is a column number of zero, which asks for a whole row.One measured divergence remains: with array arithmetic enabled Excel returns
1, 4for=INDEX(A1:C3, {1,2}, 1)and HyperFormula returns1. INDEX declaresvectorizationForbidden, as every other array-output function does, because a vectorized call evaluates the function once per element and rejects an array result — without it=INDEX(A1:C3, {1,0}, 1)crashed the engine withError: Function returning array cannot be vectorized.This is recorded in list-of-differences.md and in the migration guide.How array results work. A result spanning more than one cell is an array, so the sheet has to reserve space for it before the formula is evaluated.
INDEXdeclares asizeOfResultArrayMethod, and the shape is derived from the formula alone:indexArraySizeandindexshare oneresolveIndexArgumentshelper, so the predicted shape cannot disagree with the value returned later. A shape that does not follow from the formula — a computed index, a named expression, an unbounded range — is predicted as a single cell:=SUM(INDEX(A1:C3, B1, 0))still sums the whole row, but=INDEX(A1:C3, B1, 0)on its own returns#VALUE!.Not implemented, because both are blocked by the parser rather than by INDEX (see known limitations):
INDEX(reference, row_num, column_num, area_num)— HyperFormula has no multi-area references ((A1:B1,A2:B2)is a parsing error).=SUM(A1:INDEX(A1:A10, 5))is a parsing error; only a cell reference orOFFSETmay sit there.How did you test your changes?
npx jest→ 502 suites, 6264 tests, 0 failures. No test outside the INDEX spec needed a change.npx tsc --noEmitclean;npx eslintreports 0 errors. Codecov: all modified and coverable lines covered.npm run docs:generate-function-docsregenerates cleanly; the generated syntax line isINDEX(range, row, [column]).ArraySizethrowsError('Resizing to smaller array')(which the shared helper prevents); a vectorized array result throwsError('Function returning array cannot be vectorized.')(whichvectorizationForbiddenprevents); an unbounded range's predicted dimension isNumber.POSITIVE_INFINITYandArrayValue.resizewill not grow toward it, which silently truncated results until commit 7;addRows/addColumns/removeColumnsresize the INDEX array vertex as they do forTRANSPOSE.performance/exercises INDEX, so the perf job cannot see this change; its deltas on this branch are noise.Types of changes
Related issues:
Checklist:
Note on versioning. The migration guide is written as 3.x → 4.0 and registered in the docs sidebar next to the existing ones. If these changes are meant to ship in a minor release instead, the file needs renaming and the four changelog links updating — say so and I will.
The decisions and the engine constraints behind them are recorded in the ADR in the paired pull request (
dev_docs/2026-08-28-index-excel-compatibility.md).