diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ecffc1c..90a754210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added the `TAKE` dynamic-array function. [#1722](https://github.com/handsontable/hyperformula/pull/1722) + +### Changed + +- **Breaking change**: Replaced the placeholder English `VSTACK` and `HSTACK` names with their official localized names in 14 language packs. Formulas parsed with one of those language packs must use the localized function names. [#1722](https://github.com/handsontable/hyperformula/pull/1722) + ## [3.4.0] - 2026-08-10 ### Added diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index fae264e4b..11433559d 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -106,6 +106,8 @@ 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 | +| TAKE | =TAKE(A1:A3, 0) | N/A | No such function. | CALC | +| TAKE | =TAKE(Data!A:A, , 1) | Spills the whole column from row 1; returns SPILL below row 1. | No such function. | 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/docs/guide/release-notes.md b/docs/guide/release-notes.md index db77299be..c74f6ca7d 100644 --- a/docs/guide/release-notes.md +++ b/docs/guide/release-notes.md @@ -6,6 +6,12 @@ This page lists HyperFormula release notes. The format is based on HyperFormula adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- **Breaking change**: The Czech, Danish, German, Spanish, Finnish, French, Hungarian, Italian, Norwegian Bokmål, Dutch, Polish, Portuguese, Russian, and Turkish language packs now use the official localized names for `VSTACK` and `HSTACK`. If an existing formula uses the English `VSTACK` or `HSTACK` name while one of these language packs is active, update that formula to the localized function name exported by the language pack. + ## 3.4.0 **Release date: August 10, 2026** diff --git a/docs/guide/types-of-errors.md b/docs/guide/types-of-errors.md index 7f8546a90..92defde20 100644 --- a/docs/guide/types-of-errors.md +++ b/docs/guide/types-of-errors.md @@ -23,4 +23,4 @@ according to the language settings. | #VALUE! | Wrong type of argument | It occurs when a formula tries to improperly use different types of data. For example, you will see this error when you will try to add a string to a number. | | #CYCLE! | Circular reference | It occurs when a formula refers to its own cell, both directly and indirectly. | | #ERROR! | An error occurred | It indicates that there is an unknown error in a formula. | -| #LIC! | Invalid license key | It occurs when the license key is invalid, expired, or missing. | \ No newline at end of file +| #LIC! | Invalid license key | It occurs when the license key is invalid, expired, or missing. | diff --git a/src/error-message.ts b/src/error-message.ts index 5e3afdbea..0d9dfb7b9 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -66,6 +66,7 @@ export class ErrorMessage { public static IncorrectDateTime = 'String does not represent correct DateTime.' public static CharacterCodeBounds = 'Character code out of bounds.' public static NonZero = 'Argument cannot be 0.' + public static ZeroRowOrColumnCount = 'Row or column count cannot be zero.' public static LessThanOne = 'Argument cannot be less than 1.' public static WeekendString = 'Incorrect weekend bitmask string.' public static InvalidRoman = 'Invalid roman numeral.' diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 830b1c196..2e132b159 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'SROVNAT.SVISLE', + HSTACK: 'SROVNAT.VODOROVNĚ', ADDRESS: 'ODKAZ', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ODPIS.NELIN', T: 'T', + TAKE: 'VZÍT', TAN: 'TG', TANH: 'TGH', TBILLEQ: 'TBILLEQ', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 90296fd42..d349a1449 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'LSTAK', + HSTACK: 'VSTAK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAFSKRIVNING', T: 'T', + TAKE: 'TAG', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'STATSOBLIGATION', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index daaaa0bf7..43e1362a3 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VSTAPELN', + HSTACK: 'HSTAPELN', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'DIA', T: 'T', + TAKE: 'ÜBERNEHMEN', TAN: 'TAN', TANH: 'TANHYP', TBILLEQ: 'TBILLÄQUIV', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index d271b732c..e8286f5da 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -227,6 +227,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'SWITCH', SYD: 'SYD', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILLEQ', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 6fea52e4e..48df21d5e 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -19,8 +19,8 @@ export const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'APILARV', + HSTACK: 'APILARH', ADDRESS: 'DIRECCION', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ export const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', + TAKE: 'TOMAR', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'LETRA.DE.TEST.EQV.A.BONO', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index a3b319af2..57491a6fb 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VPINO', + HSTACK: 'HPINO', ADDRESS: 'OSOITE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'VUOSIPOISTO', T: 'T', + TAKE: 'OTA', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'OBLIG.TUOTTOPROS', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index a29109053..a99ffa240 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'ASSEMB.V', + HSTACK: 'ASSEMB.H', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', + TAKE: 'PRENDRE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TAUX.ESCOMPTE.R', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index fafc2c4b9..6095f001b 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'FÜGG.HALMOZÁS', + HSTACK: 'VÍZSZ.HALMOZÁS', ADDRESS: 'CÍM', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÉSZÖ', T: 'T', + TAKE: 'ÁTHELYEZ', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'KJEGY.EGYENÉRT', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 719cb11db..337e32d57 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -227,6 +227,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'BERALIH', SYD: 'SYD', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILL.SAMA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 40ec9cf41..43fb67409 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'STACK.VERT', + HSTACK: 'STACK.ORIZ', ADDRESS: 'INDIRIZZO', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'AMMORT.ANNUO', T: 'T', + TAKE: 'INCLUDI', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'BOT.EQUIV', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 89c0f85f7..9cc2f3c48 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VSTAKK', + HSTACK: 'HSTAKK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAVS', T: 'T', + TAKE: 'TA', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILLEKV', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 73ef99687..34e27f436 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VERT.STAPELEN', + HSTACK: 'HOR.STAPELEN', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', + TAKE: 'NEMEN', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'SCHATK.OBL', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 003b0fd64..e24b4f8e4 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'STOS.PION', + HSTACK: 'STOS.POZ', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'PRZEŁĄCZ', SYD: 'SYD', T: 'T', + TAKE: 'WYCINEK', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'RENT.EKW.BS', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 408d7f772..3d45eb9d5 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'JUNTARV', + HSTACK: 'JUNTARH', ADDRESS: 'ENDEREÇO', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SDA', T: 'T', + TAKE: 'INCLUIR', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'OTN', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 657b0db37..b8b137821 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'ВСТОЛБИК', + HSTACK: 'ГСТОЛБИК', ADDRESS: 'АДРЕС', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'АСЧ', T: 'Т', + TAKE: 'ВЗЯТЬ', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'РАВНОКЧЕК', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 5f8758008..7d4ba7b46 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAVSKR', T: 'T', + TAKE: 'TA', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'SSVXEKV', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index dc243600d..9524f793f 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'DÜŞEYYIĞ', + HSTACK: 'YATAYYIĞ', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'YAT', T: 'T', + TAKE: 'AL', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'HTAHEŞ', diff --git a/src/interpreter/functionMetadata/buildFunctionDescriptions.ts b/src/interpreter/functionMetadata/buildFunctionDescriptions.ts index 8d1cdde77..176f2e1e6 100644 --- a/src/interpreter/functionMetadata/buildFunctionDescriptions.ts +++ b/src/interpreter/functionMetadata/buildFunctionDescriptions.ts @@ -23,7 +23,7 @@ * placeholder. */ -import {FunctionMetadata, FunctionArgument} from '../plugin/FunctionPlugin' +import {FunctionMetadata, FunctionArgument, isFunctionArgumentOptional} from '../plugin/FunctionPlugin' import {CUSTOM_FUNCTION_CATEGORY, FunctionDoc, FunctionListEntry, FunctionDetails, FunctionParameterDescription} from './FunctionDescription' /** Resolves a function's display name: the translation for the active language, or the canonical id as fallback. */ @@ -33,12 +33,14 @@ type TranslateName = (canonicalName: string) => string | undefined export type StructuralMetadata = Pick /** - * Returns whether a parameter may be omitted: it declares `optionalArg`, or it has a `defaultValue`. + * Returns whether a parameter may be omitted. An explicit `optionalArg` + * declaration takes precedence; otherwise, a `defaultValue` makes the + * parameter optional. * * @param {FunctionArgument | undefined} arg - the structural argument metadata, or `undefined` */ export function isParameterOptional(arg: FunctionArgument | undefined): boolean { - return arg?.optionalArg === true || arg?.defaultValue !== undefined + return isFunctionArgumentOptional(arg) } /** diff --git a/src/interpreter/functionMetadata/categories/lookup-and-reference.ts b/src/interpreter/functionMetadata/categories/lookup-and-reference.ts index ef6b848d7..d6089cd0d 100644 --- a/src/interpreter/functionMetadata/categories/lookup-and-reference.ts +++ b/src/interpreter/functionMetadata/categories/lookup-and-reference.ts @@ -121,6 +121,13 @@ export const LOOKUP_AND_REFERENCE_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=SORT(A1:A10)', '=SORT(A1:B10, 2, -1)'], }, + TAKE: { + category: 'Lookup and reference', + shortDescription: 'Returns specified rows or columns from the beginning or end of an array.', + parameters: [{name: 'array', description: 'The array or range from which to take rows or columns.'}, {name: 'rows', description: 'The number of rows to take; a negative value takes rows from the end. An empty argument keeps all rows.'}, {name: 'columns', description: 'The number of columns to take; a negative value takes columns from the end. When omitted or empty, all columns are kept.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=TAKE(A1:C5, 2)', '=TAKE(A1:C5, -2, -1)', '=TAKE(A1:C5, , 2)'], + }, TRANSPOSE: { category: 'Lookup and reference', shortDescription: 'Transposes the rows and columns of an array.', diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 27b47096e..13876bc65 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -3,17 +3,113 @@ * 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' +type TakeLiteralDimension = + | {kind: 'value', value: number} + | {kind: 'invalid'} + | {kind: 'unresolved'} + export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { + /** + * Evaluates the dependency-free subset of TAKE count expressions used for + * static result-size prediction. + * + * @param {Ast | undefined} argument - The count expression to inspect before evaluation. + * @returns {TakeLiteralDimension} The constant value, an invalid-literal marker, or an unresolved marker. + * @internal + */ + private parseTakeLiteralNumber(argument: Ast | undefined): TakeLiteralDimension { + if (argument?.type === AstNodeType.NUMBER) { + return {kind: 'value', value: argument.value} + } + + if (argument?.type === AstNodeType.STRING) { + const coercedValue = this.arithmeticHelper.coerceToMaybeNumber(argument.value) + if (coercedValue === undefined) { + return {kind: 'invalid'} + } + return {kind: 'value', value: getRawValue(coercedValue)} + } + + if (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) { + const dimension = this.parseTakeLiteralNumber(argument.value) + if (dimension.kind !== 'value') { + return dimension + } + return {kind: 'value', value: argument.type === AstNodeType.MINUS_UNARY_OP ? -dimension.value : dimension.value} + } + + if (argument?.type === AstNodeType.PARENTHESIS) { + return this.parseTakeLiteralNumber(argument.expression) + } + + if (argument?.type === AstNodeType.FUNCTION_CALL && argument.args.length === 0) { + if (argument.procedureName === 'TRUE') { + return {kind: 'value', value: 1} + } + if (argument.procedureName === 'FALSE') { + return {kind: 'value', value: 0} + } + } + + if (argument?.type === AstNodeType.PLUS_OP) { + const left = this.parseTakeLiteralNumber(argument.left) + const right = this.parseTakeLiteralNumber(argument.right) + if (left.kind === 'invalid' || right.kind === 'invalid') { + return {kind: 'invalid'} + } + if (left.kind === 'value' && right.kind === 'value') { + return {kind: 'value', value: left.value + right.value} + } + } + + return {kind: 'unresolved'} + } + + /** + * Converts a statically resolved TAKE count into its output dimension. + * + * @param {Ast | undefined} argument - The count expression to classify. + * @returns {TakeLiteralDimension} The non-negative truncated dimension or its unresolved classification. + * @internal + */ + private parseTakeLiteralDimension(argument: Ast | undefined): TakeLiteralDimension { + const dimension = this.parseTakeLiteralNumber(argument) + return dimension.kind === 'value' + ? {kind: 'value', value: Math.abs(Math.trunc(dimension.value))} + : dimension + } + + /** + * Resolves a direct TAKE source reference without evaluating its values. + * The range supplies materialized dimensions only; spill placement never + * depends on its sheet. + * + * @param {Ast} argument - The source expression to inspect. + * @param {InterpreterState} state - The formula state used to resolve relative addresses. + * @returns {AbsoluteCellRange | undefined} The source range, or `undefined` for a computed array. + * @internal + */ + private takeSourceRange(argument: Ast, state: InterpreterState): AbsoluteCellRange | undefined { + if (argument.type === AstNodeType.PARENTHESIS) { + return this.takeSourceRange(argument.expression, state) + } + if (argument.type === AstNodeType.CELL_RANGE || argument.type === AstNodeType.COLUMN_RANGE || argument.type === AstNodeType.ROW_RANGE) { + return AbsoluteCellRange.fromAstOrUndef(argument, state.formulaAddress) + } + return undefined + } + public static implementedFunctions: ImplementedFunctions = { 'ARRAYFORMULA': { method: 'arrayformula', @@ -43,6 +139,17 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche ], repeatLastArgs: 1, }, + 'TAKE': { + method: 'take', + sizeOfResultArrayMethod: 'takeArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER, defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true}, + ], + vectorizationForbidden: true, + }, 'VSTACK': { method: 'vstack', sizeOfResultArrayMethod: 'vstackArraySize', @@ -166,6 +273,121 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return new ArraySize(width, height) } + /** + * Corresponds to TAKE(array, rows, [columns]). + * + * Returns rows and columns from the beginning or end of the source array. + * Syntactically empty dimensions keep all rows or columns. Counts that + * evaluate to zero return a #N/A error. + * + * @param {ProcedureAst} ast - The parsed TAKE call. + * @param {InterpreterState} state - The current formula evaluation state. + * @returns {InterpreterValue} The selected source values or a spreadsheet error. + */ + public take(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + // The default supports TAKE(array, , columns), but the rows argument + // position must still be present. + if (ast.args.length < 2) { + return new CellError(ErrorType.NA, ErrorMessage.WrongArgNumber) + } + + return this.runFunction(ast.args, state, this.metadata('TAKE'), + (range: SimpleRangeValue, rows: number, columns: number) => { + const sourceHeight = range.height() + const sourceWidth = range.width() + const requestedRows = Math.trunc(rows) + const requestedColumns = Math.trunc(columns) + + if (requestedRows === 0 || requestedColumns === 0) { + return new CellError(ErrorType.NA, ErrorMessage.ZeroRowOrColumnCount) + } + + if (sourceHeight === 0 || sourceWidth === 0) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyRange) + } + + const rowsToTake = Math.min(Math.abs(requestedRows), sourceHeight) + const columnsToTake = Math.min(Math.abs(requestedColumns), sourceWidth) + const startRow = requestedRows > 0 ? 0 : sourceHeight - rowsToTake + const startColumn = requestedColumns > 0 ? 0 : sourceWidth - columnsToTake + const sourceRange = range.range + + if (sourceRange !== undefined) { + const leavesHeightUnbounded = !Number.isFinite(sourceRange.height()) && !Number.isFinite(requestedRows) + const leavesWidthUnbounded = !Number.isFinite(sourceRange.width()) && !Number.isFinite(requestedColumns) + const startsBelowFirstRow = leavesHeightUnbounded && state.formulaAddress.row !== 0 + const startsRightOfFirstColumn = leavesWidthUnbounded && state.formulaAddress.col !== 0 + + if (startsBelowFirstRow || startsRightOfFirstColumn) { + return new CellError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult) + } + + // Keep address-backed ranges lazy to avoid materializing cells outside the TAKE result. + const resultRange = AbsoluteCellRange.spanFrom( + sourceRange.getAddress(startColumn, startRow), + columnsToTake, + rowsToTake, + ) + const result = SimpleRangeValue.onlyRange(resultRange, this.dependencyGraph).data + return SimpleRangeValue.onlyValues(result) + } + + const result = range.data + .slice(startRow, startRow + rowsToTake) + .map(row => row.slice(startColumn, startColumn + columnsToTake)) + + return SimpleRangeValue.onlyValues(result) + } + ) + } + + /** + * Calculates the spilled array size of TAKE using the source dimensions as + * the upper bound. Unbounded dimensions are valid only at the corresponding + * output-sheet edge, then direct references use their effective dimensions + * to register the materialized spill footprint. + * + * @param {ProcedureAst} ast - The parsed TAKE call. + * @param {InterpreterState} state - The formula state whose address anchors the spill. + * @returns {ArraySize} The predicted result dimensions or an invalid size. + */ + public takeArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 2 || ast.args.length > 3) { + return ArraySize.error() + } + + const metadata = this.metadata('TAKE') + const sourceSize = this.arraySizeForAst( + ast.args[0], + new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), + ) + + const rowDimension = this.parseTakeLiteralDimension(ast.args[1]) + const columnDimension = this.parseTakeLiteralDimension(ast.args[2]) + + if (rowDimension.kind === 'invalid' || columnDimension.kind === 'invalid') { + return ArraySize.error() + } + + const height = rowDimension.kind === 'value' ? Math.min(sourceSize.height, rowDimension.value) : sourceSize.height + const width = columnDimension.kind === 'value' ? Math.min(sourceSize.width, columnDimension.value) : sourceSize.width + const startsBelowFirstRow = !Number.isFinite(height) && state.formulaAddress.row !== 0 + const startsRightOfFirstColumn = !Number.isFinite(width) && state.formulaAddress.col !== 0 + const sourceRange = this.takeSourceRange(ast.args[0], state) + const effectiveHeight = !Number.isFinite(height) && sourceRange !== undefined + ? sourceRange.effectiveHeight(this.dependencyGraph) + : height + const effectiveWidth = !Number.isFinite(width) && sourceRange !== undefined + ? sourceRange.effectiveWidth(this.dependencyGraph) + : width + + if (startsBelowFirstRow || startsRightOfFirstColumn || effectiveHeight < 1 || effectiveWidth < 1) { + return ArraySize.error() + } + + return new ArraySize(effectiveWidth, effectiveHeight) + } + /** * Corresponds to VSTACK(array1, [array2], ...) * diff --git a/src/interpreter/plugin/FunctionPlugin.ts b/src/interpreter/plugin/FunctionPlugin.ts index 810f14047..79de6453b 100644 --- a/src/interpreter/plugin/FunctionPlugin.ts +++ b/src/interpreter/plugin/FunctionPlugin.ts @@ -200,15 +200,18 @@ export interface FunctionArgument { passSubtype?: boolean, /** - * If an argument is missing, its value defaults to `defaultValue`. + * If an argument is missing and omission is allowed, its value defaults to + * `defaultValue`. Declaring a default makes the argument optional unless + * `optionalArg` is explicitly set to `false`. */ defaultValue?: InternalScalarValue | RawScalarValue, /** - * If set to `true`: - * if an argument is missing, and no `defaultValue` is set, the argument is `undefined` (instead of throwing an error). + * Explicitly controls whether the argument may be omitted. When omitted, + * the presence of `defaultValue` makes the argument optional. * - * This is logically equivalent to setting `defaultValue` to `undefined`. + * If set to `true` without a `defaultValue`, a missing argument is passed as + * `undefined` instead of producing an error. */ optionalArg?: boolean, @@ -233,8 +236,9 @@ export interface FunctionArgument { greaterThan?: number, /** - * If set to `true`, an empty argument is treated as if the argument was not - * provided at all — that is, it falls back to `defaultValue`. + * If set to `true`, a syntactically empty argument falls back to + * `defaultValue`. This does not make the argument optional; `optionalArg` + * controls whether the argument may be omitted. * * By default (`false`), an empty argument is coerced to the zero-value for its * type (`0` for numbers, `FALSE` for booleans, `""` for strings). @@ -249,6 +253,18 @@ export interface FunctionArgument { emptyAsDefault?: boolean, } +/** + * Returns whether a function argument may be omitted. + * + * Arguments explicitly marked as optional and arguments with default values + * may be omitted. + * + * @param {FunctionArgument | undefined} argument - The argument metadata to inspect. + */ +export function isFunctionArgumentOptional(argument: FunctionArgument | undefined): boolean { + return argument?.optionalArg || argument?.defaultValue !== undefined +} + export type PluginFunctionType = (ast: ProcedureAst, state: InterpreterState) => InterpreterValue export type PluginArraySizeFunctionType = (ast: ProcedureAst, state: InterpreterState) => ArraySize @@ -539,7 +555,7 @@ export abstract class FunctionPlugin implements FunctionPluginTypecheck argMetadata?.optionalArg || argMetadata?.defaultValue !== undefined) + const areMissingArgumentsOptional = metadataForMissingArguments.every(isFunctionArgumentOptional) return areMissingArgumentsOptional }