Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- Added the `CHOOSECOLS` dynamic-array function. [#1734](https://github.com/handsontable/hyperformula/pull/1734)

## [3.4.0] - 2026-08-10

### Added
Expand Down
8 changes: 8 additions & 0 deletions docs/guide/known-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ a circular reference.

* Ordering (including mixed types, empty cells, and text collation) follows HyperFormula's own comparison rules, which honor the `caseSensitive` and `accentSensitive` configuration options. Numbers sort before text, and text before logical values.

### CHOOSECOLS function

* Column indexes must be supplied as separate scalar arguments. Passing multiple indexes through an array or range argument is not supported.

* A whole-column source can spill when the formula is in the first row, regardless of whether the source is on the same sheet. A formula below the first row returns `#SPILL!` because its result would extend beyond the worksheet edge. An empty source returns `#N/A` because its effective range is empty. Finite-height sources are supported.

* HyperFormula reserves a dynamic array's predicted spill range before evaluating the formula. If that range is blocked, a runtime error in a source or non-literal column-index expression can therefore be reported as `#SPILL!`. Errors in literal column indexes are detected before spill allocation.

### OFFSET function

HyperFormula resolves the OFFSET function at parse time rather than during evaluation. The parser inspects the arguments and rewrites the expression into a plain cell reference or range. This keeps the dependency graph accurate but imposes several restrictions.
Expand Down
1 change: 1 addition & 0 deletions docs/guide/list-of-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ To remove the differences, create [custom implementations](custom-functions.md)
| NORMSDIST | =NORMSDIST(0, TRUE()) | 0.5 | Wrong number | Wrong number |
| ADDRESS | =ADDRESS(1,1,4, TRUE(), "") | !A1 | ''!A1 | !A1 |
| SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC |
| CHOOSECOLS | =CHOOSECOLS(Data!A:A, 1) | Spills the whole column from row 1; returns SPILL below row 1. | Spills the whole column when space is available. | Spills the whole column from row 1; returns SPILL below row 1. |
| INT | =INT(-8.9) | -8 | -9 | -9 |
| MOD | =MOD(-10, 3) | -1 | 2 | 2 |
| ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE |
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/csCZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#HODNOTA!',
},
functions: {
CHOOSECOLS: 'ZVOLITSLOUPCE',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/daDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#VÆRDI!',
},
functions: {
CHOOSECOLS: 'VÆLGKOL',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/deDE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#WERT!',
},
functions: {
CHOOSECOLS: 'SPALTENWAHL',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/enGB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#VALUE!',
},
functions: {
CHOOSECOLS: 'CHOOSECOLS',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/esES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const dictionary: RawTranslationPackage = {
VALUE: '#¡VALOR!',
},
functions: {
CHOOSECOLS: 'ELEGIRCOLS',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/fiFI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#ARVO!',
},
functions: {
CHOOSECOLS: 'VALITSESARAKKEET',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/frFR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#VALEUR!',
},
functions: {
CHOOSECOLS: 'CHOISIRCOLS',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/huHU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#ÉRTÉK!',
},
functions: {
CHOOSECOLS: 'OSZLOPVÁLASZTÁS',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/idID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#NILAI!',
},
functions: {
CHOOSECOLS: 'CHOOSECOLS',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/itIT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#VALORE!',
},
functions: {
CHOOSECOLS: 'SCEGLI.COL',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/nbNO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#VERDI!',
},
functions: {
CHOOSECOLS: 'VELGKOL',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/nlNL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#WAARDE!',
},
functions: {
CHOOSECOLS: 'KIES.KOLOMMEN',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/plPL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#ARG!',
},
functions: {
CHOOSECOLS: 'WYBIERZ.KOLUMNY',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/ptPT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#VALOR!',
},
functions: {
CHOOSECOLS: 'ESCOLHERCOLS',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/ruRU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#ЗНАЧ!',
},
functions: {
CHOOSECOLS: 'ВЫБОРСТОЛБЦ',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/svSE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#VÄRDEFEL!',
},
functions: {
CHOOSECOLS: 'VÄLJKOL',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/trTR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = {
VALUE: '#DEĞER!',
},
functions: {
CHOOSECOLS: 'SÜTUNSEÇ',
FILTER: 'FILTER',
VSTACK: 'VSTACK',
HSTACK: 'HSTACK',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export const LOOKUP_AND_REFERENCE_DOCS: Record<string, FunctionDoc> = {
documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html',
examples: ['=CHOOSE(2, "apple", "banana", "cherry")', '=CHOOSE(1, A1, A2, A3)'],
},
CHOOSECOLS: {
category: 'Lookup and reference',
shortDescription: 'Returns specified columns from an array.',
parameters: [{name: 'array', description: 'The array or range containing the columns to return.'}, {name: 'col_num1', description: 'The first column to return. Positive values count from the left and negative values count from the right. Further column indexes can be passed as additional arguments.'}],
documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html',
examples: ['=CHOOSECOLS(A1:E5, 1, 3, 5)', '=CHOOSECOLS(A1:D5, -1, -2)'],
},
COLUMN: {
category: 'Lookup and reference',
shortDescription: 'Returns column number of a given reference or formula reference if argument not provided.',
Expand Down
172 changes: 170 additions & 2 deletions src/interpreter/plugin/ArrayPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,58 @@
* Copyright (c) 2025 Handsoncode. All rights reserved.
*/

import {AbsoluteCellRange} from '../../AbsoluteCellRange'
import {ArraySize} from '../../ArraySize'
import {CellError, ErrorType} from '../../Cell'
import {ErrorMessage} from '../../error-message'
import {AstNodeType, ProcedureAst} from '../../parser'
import {Ast, AstNodeType, ProcedureAst} from '../../parser'
import {coerceScalarToBoolean} from '../ArithmeticHelper'
import {InterpreterState} from '../InterpreterState'
import {InternalScalarValue, InterpreterValue} from '../InterpreterValue'
import {getRawValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue'
import {SimpleRangeValue} from '../../SimpleRangeValue'
import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin'

/** A CHOOSECOLS index classified without evaluating a formula expression. */
type ChooseColsLiteralIndex =
| {kind: 'value', value: number}
| {kind: 'invalid'}
| {kind: 'unresolved'}

export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck<ArrayPlugin> {
/**
* Classifies an index literal for static CHOOSECOLS result-size prediction.
*
* @param {Ast} argument - The column-index argument to inspect without evaluating formulas.
* @returns {ChooseColsLiteralIndex} A coerced literal value, an invalid marker, or an unresolved marker.
*/
private parseChooseColsLiteralIndex(argument: Ast): ChooseColsLiteralIndex {
Comment thread
Tobiadefami marked this conversation as resolved.
if (argument.type === AstNodeType.NUMBER) {
return {kind: 'value', value: Math.trunc(argument.value)}
}

if (argument.type === AstNodeType.STRING) {
const coercedValue = this.arithmeticHelper.coerceToMaybeNumber(argument.value)
if (coercedValue === undefined) {
return {kind: 'invalid'}
}
return {kind: 'value', value: Math.trunc(getRawValue(coercedValue))}
}

if (argument.type === AstNodeType.PLUS_UNARY_OP && argument.value.type === AstNodeType.NUMBER) {
return {kind: 'value', value: Math.trunc(argument.value.value)}
}

if (argument.type === AstNodeType.MINUS_UNARY_OP && argument.value.type === AstNodeType.NUMBER) {
return {kind: 'value', value: Math.trunc(-argument.value.value)}
}

if (argument.type === AstNodeType.PARENTHESIS) {
return this.parseChooseColsLiteralIndex(argument.expression)
}

return {kind: 'unresolved'}
}
Comment thread
cursor[bot] marked this conversation as resolved.

public static implementedFunctions: ImplementedFunctions = {
'ARRAYFORMULA': {
method: 'arrayformula',
Expand Down Expand Up @@ -43,6 +84,17 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche
],
repeatLastArgs: 1,
},
'CHOOSECOLS': {
method: 'choosecols',
sizeOfResultArrayMethod: 'choosecolsArraySize',
enableArrayArithmeticForArguments: true,
parameters: [
{argumentType: FunctionArgumentType.RANGE},
{argumentType: FunctionArgumentType.NUMBER},
],
repeatLastArgs: 1,
vectorizationForbidden: true,
},
'VSTACK': {
method: 'vstack',
sizeOfResultArrayMethod: 'vstackArraySize',
Expand Down Expand Up @@ -166,6 +218,122 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche
return new ArraySize(width, height)
}

/**
* Corresponds to CHOOSECOLS(array, col_num1, [col_num2], ...).
*
* Returns the requested source columns in argument order. Positive indexes
* count from the left, negative indexes count from the right, and duplicate
* indexes duplicate their columns in the result.
*
* @param {ProcedureAst} ast - The parsed function-call AST node.
* @param {InterpreterState} state - The current interpreter evaluation state.
* @returns {InterpreterValue} The selected source columns or a spreadsheet error.
*/
public choosecols(ast: ProcedureAst, state: InterpreterState): InterpreterValue {
return this.runFunction(ast.args, state, this.metadata('CHOOSECOLS'),
(range: SimpleRangeValue, ...columnNumbers: number[]) => {
const sourceWidth = range.width()
const sourceHeight = range.height()

if (sourceHeight === 0 || sourceWidth === 0) {
return new CellError(ErrorType.NA, ErrorMessage.EmptyRange)
}

const columnIndexes = columnNumbers.map(columnNumber => Math.trunc(columnNumber))

if (columnIndexes.some(columnIndex =>
!Number.isFinite(columnIndex) || columnIndex === 0 || Math.abs(columnIndex) > sourceWidth
)) {
return new CellError(ErrorType.VALUE, ErrorMessage.IndexBounds)
}

const zeroBasedColumnIndexes = columnIndexes.map(columnIndex =>
columnIndex > 0 ? columnIndex - 1 : sourceWidth + columnIndex
)

const sourceRange = range.range
const startsBelowFirstRow = sourceRange !== undefined
&& !Number.isFinite(sourceRange.height())
&& state.formulaAddress.row !== 0

if (startsBelowFirstRow) {
return new CellError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult)
}

if (sourceRange !== undefined) {
const selectedColumns = zeroBasedColumnIndexes.map(columnIndex => {
const columnRange = AbsoluteCellRange.spanFrom(
sourceRange.getAddress(columnIndex, 0),
1,
sourceHeight,
)
return SimpleRangeValue.onlyRange(columnRange, this.dependencyGraph).data
})
const result = Array.from({length: sourceHeight}, (_, row) =>
selectedColumns.map(column => column[row][0])
)
return SimpleRangeValue.onlyValues(result)
}

const result = range.data.map(row =>
zeroBasedColumnIndexes.map(columnIndex => row[columnIndex])
)
return SimpleRangeValue.onlyValues(result)
Comment thread
cursor[bot] marked this conversation as resolved.
}
)
}

/**
* Predicts the CHOOSECOLS spill size from the source height and index count.
*
* Invalid literals are rejected before spill allocation. A whole-column
* result is valid only in the first output row, then its source range
* supplies the materialized spill height.
*
* @param {ProcedureAst} ast - The parsed function-call AST node.
* @param {InterpreterState} state - The current interpreter evaluation state.
* @returns {ArraySize} The predicted result dimensions or an invalid size.
*/
public choosecolsArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same finding as on #1722's TAKE: CHOOSECOLS(A:A,1) returns #VALUE! even on the same sheet, where real Excel spills correctly (confirmed live via MS Graph). SORT/UNIQUE/FILTER already support this in the same codebase — worth reusing whatever they do differently in array-size prediction rather than the current blanket rejection of non-finite dimensions.

known-limitations.md's new CHOOSECOLS entry is otherwise excellent, by the way — exactly the house style (HF's own behavior + consequence, no Excel-comparison framing). Once the same-sheet case is fixed, the "Spills the whole column when space is available" line in list-of-differences.md will need to become case-specific: same-sheet works, cross-sheet genuinely does not (confirmed #SPILL! in real Excel).

if (ast.args.length < 2) {
return ArraySize.error()
}

const metadata = this.metadata('CHOOSECOLS')
const sourceSize = this.arraySizeForAst(
ast.args[0],
new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)),
)

const startsBelowFirstRow = !Number.isFinite(sourceSize.height) && state.formulaAddress.row !== 0
const sourceRange = ast.args[0].type === AstNodeType.COLUMN_RANGE
? AbsoluteCellRange.fromAstOrUndef(ast.args[0], state.formulaAddress)
: undefined
const effectiveHeight = !Number.isFinite(sourceSize.height) && sourceRange !== undefined
? sourceRange.effectiveHeight(this.dependencyGraph)
: sourceSize.height

if (startsBelowFirstRow || effectiveHeight < 1) {
return ArraySize.error()
Comment thread
cursor[bot] marked this conversation as resolved.
}

for (const argument of ast.args.slice(1)) {
const index = this.parseChooseColsLiteralIndex(argument)
if (
index.kind === 'invalid'
|| (index.kind === 'value' && (
!Number.isFinite(index.value)
|| index.value === 0
|| Math.abs(index.value) > sourceSize.width
))
) {
return ArraySize.error()
}
}

return new ArraySize(ast.args.length - 1, effectiveHeight)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finite height breaks column spills

High Severity

choosecolsArraySize now materializes whole-column results with a finite effectiveHeight snapshot instead of keeping an unbounded height. Array size is fixed when the formula is set, so when the source sheet later grows, evaluation returns more rows than predicted and ArrayValue.resize throws. SORT/UNIQUE/FILTER keep POSITIVE_INFINITY here so spill stays an AbsoluteColumnRange and can expand.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0df4d1f. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reran this against the current PR commit (0df4d1f28c) using both cross-sheet and same-sheet whole-column formulas.

One clarification: updating a source cell does reevaluate the formula, but it does not rerun the array-size predictor or rebuild the formula vertex.

Test setup

I started with three values in column A and then extended the sheet’s used height by adding a fourth value:

  hf.setCellContents(
    { sheet: dataSheet, col: 0, row: 3 },
    40,
  )

I tested the following cross-sheet formulas:

=CHOOSECOLS(Data!A:A, 1)
=SORT(Data!A:A)
=UNIQUE(Data!A:A)

I repeated the test using same-sheet references:

=CHOOSECOLS(A:A, 1)
=SORT(A:A)
=UNIQUE(A:A)

Result

All three functions throw the same error when the new value extends the used height of the source range:

Error: Resizing to smaller array
    at ArrayValue.resize (.../src/ArrayValue.ts:141:13)
    at ArrayFormulaVertex.setCellValue (.../src/DependencyGraph/FormulaVertex.ts:132:11)
    at Evaluator.recomputeFormulaVertexValue (.../src/Evaluator.ts:141:21)

For the same-sheet tests, SORT and UNIQUE initially spill all three rows correctly. However, they still throw after adding the fourth source value.

The following control cases succeed:

  • Updating a value within the source’s existing used height.
  • Using a finite source such as Data!A1:A4 and then populating A4.

Could you rerun the source-growth check against SORT and UNIQUE?

Based on these results, they do not currently expand successfully after the used height of a whole-column source increases.

It also appears that preserving Infinity during the initial array-size prediction is insufficient on its own. After the first evaluation, the array formula vertex holds the finite size of the materialized result. The subsequent, larger result then fails in ArrayValue.resize.

This may therefore require an engine-level change to spill allocation or predicted-size retention -- or another mechanism that allows an existing array formula vertex to grow during recalculation.

}

/**
* Corresponds to VSTACK(array1, [array2], ...)
*
Expand Down
Loading