Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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 produces an unbounded-height result, which HyperFormula cannot allocate as a direct spill. Such a formula returns `#VALUE!`; if the source sheet has no populated rows, it returns `#N/A` because the effective source 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) | VALUE | Spills the whole column when space is available. | Spills the whole column when space is available. |
| 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
153 changes: 151 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,103 @@ 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.
*/
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
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 and unbounded result heights are rejected before spill
* allocation so a neighboring cell cannot mask a statically known error.
*
* @param {ProcedureAst} ast - The parsed function-call AST node.
* @param {InterpreterState} state - The current interpreter evaluation state.
*/
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)),
)

if (!Number.isFinite(sourceSize.height) || sourceSize.height < 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, sourceSize.height)
}

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