Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 same-sheet whole-column source can spill when the formula is in the first row. Cross-sheet whole-column sources and same-sheet formulas below the first row return `#SPILL!` because HyperFormula cannot reserve their unbounded result range. An empty cross-sheet source returns `#N/A` because its 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) | SPILL | Spills the whole column when space is available. | SPILL |
| 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
170 changes: 168 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 {

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.

codecov/patch is red because of exactly two untested lines: the PLUS_UNARY_OP branch (e.g. =CHOOSECOLS(A1:D3,+2)) and the PARENTHESIS branch (e.g. (2)). One or two literal-index tests for these should close it completely.

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'}
}

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,120 @@ 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
&& !Number.isFinite(sourceRange.height())
&& (sourceRange.sheet !== state.formulaAddress.sheet || state.formulaAddress.row !== 0)
) {
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 and unsupported unbounded result heights are rejected
* before spill allocation so a neighboring cell cannot mask a statically
* known error. Same-sheet whole-column results can retain their unbounded
* height when anchored in the first row.
*
* @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)),
)

const sourceRange = ast.args[0].type === AstNodeType.COLUMN_RANGE
? AbsoluteCellRange.fromAstOrUndef(ast.args[0], state.formulaAddress)
: undefined
const isSupportedWholeColumnResult = sourceSize.height === Number.POSITIVE_INFINITY
&& sourceRange?.sheet === state.formulaAddress.sheet
&& state.formulaAddress.row === 0

if ((!Number.isFinite(sourceSize.height) && !isSupportedWholeColumnResult) || sourceSize.height < 1) {
return ArraySize.error()
}

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