Skip to content

[BREAKING CHANGE] Make INDEX Excel-compatible for zero and omitted indices (HF-224) - #1754

Draft
sequba wants to merge 15 commits into
developfrom
feature/HF-224-index-excel-compatible
Draft

[BREAKING CHANGE] Make INDEX Excel-compatible for zero and omitted indices (HF-224)#1754
sequba wants to merge 15 commits into
developfrom
feature/HF-224-index-excel-compatible

Conversation

@sequba

@sequba sequba commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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.md recorded 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 guidedocs/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 of A2 and 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.TYPE so 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:

Formula Excel this branch
=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) 4 4
=INDEX(A1:C1, 3) 3 3
=INDEX(A1:C1, 3, ) #REF! #REF!corrected, previously 3
=INDEX(A1:C1, 4) #REF! #REF!
=INDEX(A1:A3, 2) 4 4
=INDEX(A1:C3, 2, 2) 5 5
=INDEX(A1:C3, 2, 0) 4, 5, 6 4, 5, 6
=INDEX(A1:C3, 0, 2) 2; 5; 8 2; 5; 8
=INDEX(A1:C3, 0, 0) the whole range the whole range
=INDEX(A1:C3, 2) #REF! #REF!corrected, previously the whole row
=SUM(INDEX(A1:C3, 0, 1)) 12 12

The rule the data supports, and what is now implemented: leaving column_num out is not the same as passing 0. 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, 4 for =INDEX(A1:C3, {1,2}, 1) and HyperFormula returns 1. INDEX declares vectorizationForbidden, 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 with Error: 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. INDEX declares a sizeOfResultArrayMethod, and the shape is derived from the formula alone: indexArraySize and index share one resolveIndexArguments helper, 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):

  • Excel's reference form INDEX(reference, row_num, column_num, area_num) — HyperFormula has no multi-area references ((A1:B1,A2:B2) is a parsing error).
  • INDEX as a range endpoint — =SUM(A1:INDEX(A1:A10, 5)) is a parsing error; only a cell reference or OFFSET may sit there.

How did you test your changes?

  • Paired test pull request: handsontable/hyperformula-tests#47 — 59 cases, including one per row of the Excel table above.
  • Full private suite: npx jest → 502 suites, 6264 tests, 0 failures. No test outside the INDEX spec needed a change.
  • npx tsc --noEmit clean; npx eslint reports 0 errors. Codecov: all modified and coverable lines covered.
  • npm run docs:generate-function-docs regenerates cleanly; the generated syntax line is INDEX(range, row, [column]).
  • Engine behaviour was established by experiment, not by reading: a runtime array larger than the predicted ArraySize throws Error('Resizing to smaller array') (which the shared helper prevents); a vectorized array result throws Error('Function returning array cannot be vectorized.') (which vectorizationForbidden prevents); an unbounded range's predicted dimension is Number.POSITIVE_INFINITY and ArrayValue.resize will not grow toward it, which silently truncated results until commit 7; addRows/addColumns/removeColumns resize the INDEX array vertex as they do for TRANSPOSE.
  • Worth knowing for review: no benchmark in performance/ exercises INDEX, so the perf job cannot see this change; its deltas on this branch are noise.

Types of changes

  • Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore)
  • New feature or improvement (a non-breaking change that adds functionality)
  • Bug fix (a non-breaking change that fixes an issue)
  • Additional language file, or a change to an existing language file (translations)
  • Change to the documentation

Related issues:

  1. HF-224
  2. Paired tests: handsontable/hyperformula-tests#47

Checklist:

  • I have reviewed the guidelines about Contributing to HyperFormula and I confirm that my code follows the code style of this project.
  • I have signed the Contributor License Agreement.
  • My change is compliant with the OpenDocument standard.
  • My change is compatible with Microsoft Excel.
  • My change is compatible with Google Sheets.
  • I described my changes in the CHANGELOG.md file.
  • My changes require a documentation update.
  • My changes require a migration guide.

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).

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
@qunabu

qunabu commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Task linked: HF-224 Make INDEX function Excel-compatible

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 28, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

claude added 2 commits August 28, 2026 13:25
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
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

Performance comparison of head (3ae0e20) vs base (419028a)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  396.74 |  397.44 | +0.18%
                                      Sheet B |     119 |  121.81 | +2.36%
                                      Sheet T |  106.57 |  107.18 | +0.57%
                                Column ranges |  512.24 |  509.56 | -0.52%
                                Sorted lookup | 15000.7 | 15233.5 | +1.55%
Sheet A:  change value, add/remove row/column |   10.86 |   10.89 | +0.28%
 Sheet B: change value, add/remove row/column |  103.58 |   94.59 | -8.68%
                   Column ranges - add column |  133.91 |  133.84 | -0.05%
                Column ranges - without batch |   415.1 |  429.03 | +3.36%
                        Column ranges - batch |  101.71 |  105.44 | +3.67%

claude added 10 commits August 28, 2026 13:47
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
@sequba sequba changed the title Make INDEX Excel-compatible for zero and omitted indices (HF-224) [BREAKING CHANGE] Make INDEX Excel-compatible for zero and omitted indices (HF-224) Aug 28, 2026
claude added 2 commits August 28, 2026 16:42
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

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.32%. Comparing base (419028a) to head (3ae0e20).

Additional details and impacted files

Impacted file tree graph

@@             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     
Files with missing lines Coverage Δ
...unctionMetadata/categories/lookup-and-reference.ts 100.00% <ø> (ø)
src/interpreter/plugin/InformationPlugin.ts 95.50% <100.00%> (+2.17%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants