diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ecffc1c..75326efa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 7f0e2361d..41a4b2974 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -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. diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index fae264e4b..af36458ab 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -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 | diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 830b1c196..e5ec28b3d 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#HODNOTA!', }, functions: { + CHOOSECOLS: 'ZVOLITSLOUPCE', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 90296fd42..96942f712 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#VÆRDI!', }, functions: { + CHOOSECOLS: 'VÆLGKOL', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index daaaa0bf7..154bd8a05 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#WERT!', }, functions: { + CHOOSECOLS: 'SPALTENWAHL', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index d271b732c..0ee54dab2 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#VALUE!', }, functions: { + CHOOSECOLS: 'CHOOSECOLS', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 6fea52e4e..aa986de10 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -18,6 +18,7 @@ export const dictionary: RawTranslationPackage = { VALUE: '#¡VALOR!', }, functions: { + CHOOSECOLS: 'ELEGIRCOLS', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index a3b319af2..cd01e0764 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#ARVO!', }, functions: { + CHOOSECOLS: 'VALITSESARAKKEET', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index a29109053..4f554cca7 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#VALEUR!', }, functions: { + CHOOSECOLS: 'CHOISIRCOLS', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index fafc2c4b9..cde0ac930 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#ÉRTÉK!', }, functions: { + CHOOSECOLS: 'OSZLOPVÁLASZTÁS', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 719cb11db..86b28902c 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#NILAI!', }, functions: { + CHOOSECOLS: 'CHOOSECOLS', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 40ec9cf41..7cd9f856b 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#VALORE!', }, functions: { + CHOOSECOLS: 'SCEGLI.COL', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 89c0f85f7..d6112fadd 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#VERDI!', }, functions: { + CHOOSECOLS: 'VELGKOL', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 73ef99687..d1fafdd5d 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#WAARDE!', }, functions: { + CHOOSECOLS: 'KIES.KOLOMMEN', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 003b0fd64..43ab5ae45 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#ARG!', }, functions: { + CHOOSECOLS: 'WYBIERZ.KOLUMNY', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 408d7f772..2472f5545 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#VALOR!', }, functions: { + CHOOSECOLS: 'ESCOLHERCOLS', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 657b0db37..5e0a636ad 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#ЗНАЧ!', }, functions: { + CHOOSECOLS: 'ВЫБОРСТОЛБЦ', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 5f8758008..b015baf76 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#VÄRDEFEL!', }, functions: { + CHOOSECOLS: 'VÄLJKOL', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index dc243600d..2a0597469 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -18,6 +18,7 @@ const dictionary: RawTranslationPackage = { VALUE: '#DEĞER!', }, functions: { + CHOOSECOLS: 'SÜTUNSEÇ', FILTER: 'FILTER', VSTACK: 'VSTACK', HSTACK: 'HSTACK', diff --git a/src/interpreter/functionMetadata/categories/lookup-and-reference.ts b/src/interpreter/functionMetadata/categories/lookup-and-reference.ts index ef6b848d7..1559d6823 100644 --- a/src/interpreter/functionMetadata/categories/lookup-and-reference.ts +++ b/src/interpreter/functionMetadata/categories/lookup-and-reference.ts @@ -25,6 +25,13 @@ export const LOOKUP_AND_REFERENCE_DOCS: Record = { 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.', diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 27b47096e..7ead942c0 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -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 { + /** + * 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 { + 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', @@ -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', @@ -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) + } + ) + } + + /** + * 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 { + 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() + } + + 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) + } + /** * Corresponds to VSTACK(array1, [array2], ...) *