diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3868bc4..ced636c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added `maxPendingLazyTransformations` configuration option to control memory usage by limiting accumulated transformations before cleanup. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Added a new function: TEXTJOIN. [#1640](https://github.com/handsontable/hyperformula/pull/1640) +- Added a new function: SEQUENCE. [#1645](https://github.com/handsontable/hyperformula/pull/1645) ### Fixed diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index eb8428e18..241d75185 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -58,6 +58,7 @@ Total number of functions: **{{ $page.functionsCount }}** | ARRAYFORMULA | Enables the array arithmetic mode for a single formula. | ARRAYFORMULA(Formula) | | FILTER | Filters an array, based on multiple conditions (boolean arrays). | FILTER(SourceArray, BoolArray1, BoolArray2, ...BoolArrayN) | | ARRAY_CONSTRAIN | Truncates an array to given dimensions. | ARRAY_CONSTRAIN(Array, Height, Width) | +| SEQUENCE | Returns an array of sequential numbers. | SEQUENCE(Rows, [Cols], [Start], [Step]) | ### Date and time diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index fb8f4bd53..72753b9b2 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -37,3 +37,4 @@ you can't compare the arguments in a formula like this: * For certain inputs, the RATE function might have no solutions, or have multiple solutions. Our implementation uses an iterative algorithm (Newton's method) to find an approximation for one of the solutions to within 1e-7. If the approximation is not found after 50 iterations, the RATE function returns the `#NUM!` error. * The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. +* Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 0c6828e5a..9dac88022 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -99,3 +99,4 @@ To remove the differences, create [custom implementations](custom-functions.md) | DEVSQ | =DEVSQ(A2, A3) | 0.0000 | 0.0000 | NUM | | 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 | diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index d3f0deaf9..2e521df6e 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SEKUNDA', + SEQUENCE: 'SEQUENCE', SHEET: 'SHEET', SHEETS: 'SHEETS', SIN: 'SIN', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 12607c126..3d3962795 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SEKUND', + SEQUENCE: 'SEKVENS', SHEET: 'ARK', SHEETS: 'ARK.FLERE', SIN: 'SIN', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 025fd81d1..b50393bc4 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECHYP', SECOND: 'SEKUNDE', + SEQUENCE: 'SEQUENZ', SHEET: 'BLATT', SHEETS: 'BLÄTTER', SIN: 'SIN', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index aa70001a3..7bcf2ee0b 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -190,6 +190,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SECOND', + SEQUENCE: 'SEQUENCE', SHEET: 'SHEET', SHEETS: 'SHEETS', SIN: 'SIN', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index a15326f25..217310790 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -188,6 +188,7 @@ export const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SEGUNDO', + SEQUENCE: 'SECUENCIA', SHEET: 'HOJA', SHEETS: 'HOJAS', SIN: 'SENO', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 9deeed016..c2cc74745 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEK', SECH: 'SEKH', SECOND: 'SEKUNNIT', + SEQUENCE: 'JAKSO', SHEET: 'TAULUKKO', SHEETS: 'TAULUKOT', SIN: 'SIN', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index dd467e7e4..1be881f80 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SECONDE', + SEQUENCE: 'SEQUENCE', SHEET: 'FEUILLE', SHEETS: 'FEUILLES', SIN: 'SIN', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 915420c49..cc4a15edf 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'MPERC', + SEQUENCE: 'SOROZAT', SHEET: 'LAP', SHEETS: 'LAPOK', SIN: 'SIN', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 000f45a1f..34279b6cb 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SECONDO', + SEQUENCE: 'SEQUENZA', SHEET: 'FOGLIO', SHEETS: 'FOGLI', SIN: 'SEN', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index d521aead4..c8ff6b754 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SEKUND', + SEQUENCE: 'SEKVENS', SHEET: 'ARK', SHEETS: 'SHEETS', SIN: 'SIN', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 1536ea5a5..d6c4a78f3 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SECONDE', + SEQUENCE: 'REEKS', SHEET: 'BLAD', SHEETS: 'BLADEN', SIN: 'SIN', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index d5651c77d..4fb84de19 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SEKUNDA', + SEQUENCE: 'SEKWENCJA', SHEET: 'ARKUSZ', SHEETS: 'ARKUSZE', SIN: 'SIN', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index ee5d9597e..63d7842d4 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SEGUNDO', + SEQUENCE: 'SEQUÊNCIA', SHEET: 'PLANILHA', SHEETS: 'PLANILHAS', SIN: 'SEN', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index d11284169..49d54ca63 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'СЕКУНДЫ', + SEQUENCE: 'ПОСЛЕДОВ', SHEET: 'ЛИСТ', SHEETS: 'ЛИСТЫ', SIN: 'SIN', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 4bc4f46c7..c48780571 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SEKUND', + SEQUENCE: 'SEKVENS', SHEET: 'SHEET', SHEETS: 'SHEETS', SIN: 'SIN', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index d23e8f2f3..26455f725 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -188,6 +188,7 @@ const dictionary: RawTranslationPackage = { SEC: 'SEC', SECH: 'SECH', SECOND: 'SANİYE', + SEQUENCE: 'SIRA', SHEET: 'SAYFA', SHEETS: 'SAYFALAR', SIN: 'SİN', diff --git a/src/interpreter/plugin/SequencePlugin.ts b/src/interpreter/plugin/SequencePlugin.ts new file mode 100644 index 000000000..08c0492de --- /dev/null +++ b/src/interpreter/plugin/SequencePlugin.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import { ArraySize } from '../../ArraySize' +import { CellError, ErrorType } from '../../Cell' +import { ErrorMessage } from '../../error-message' +import { Ast, AstNodeType, ProcedureAst } from '../../parser' +import { InterpreterState } from '../InterpreterState' +import { InterpreterValue } from '../InterpreterValue' +import { SimpleRangeValue } from '../../SimpleRangeValue' +import { FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions } from './FunctionPlugin' + +/** + * Plugin implementing the SEQUENCE spreadsheet function. + * + * SEQUENCE(rows, [cols], [start], [step]) returns a rows×cols array of + * sequential numbers starting at `start` and incrementing by `step`. + */ +export class SequencePlugin extends FunctionPlugin implements FunctionPluginTypecheck { + /** + * Minimum valid value for the `rows` and `cols` arguments. + * Extracted to avoid duplicating the check between `sequence()` (runtime) and + * `sequenceArraySize()` (parse time). + */ + private static readonly MIN_DIMENSION = 1 + + /** Returns true when `n` is a finite number at least {@link MIN_DIMENSION}. */ + private static isValidDimension(n: number): boolean { + return Number.isFinite(n) && n >= SequencePlugin.MIN_DIMENSION + } + + /** + * Parses a literal dimension from an AST node at parse time. + * Handles NUMBER nodes directly, STRING nodes via numeric coercion, + * PLUS/MINUS_UNARY_OP wrapping a NUMBER (e.g. `+3`, `-2`), + * and TRUE()/FALSE() function calls (returning 1/0). + * Returns undefined for non-literal nodes (cell refs, formulas, binary ops). + */ + private static parseLiteralDimension(node: Ast): number | undefined { + if (node.type === AstNodeType.NUMBER) { + return Math.trunc(node.value) + } + if (node.type === AstNodeType.STRING) { + const parsed = Number(node.value) + return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined + } + if (node.type === AstNodeType.PLUS_UNARY_OP && node.value.type === AstNodeType.NUMBER) { + return Math.trunc(node.value.value) + } + if (node.type === AstNodeType.MINUS_UNARY_OP && node.value.type === AstNodeType.NUMBER) { + return Math.trunc(-node.value.value) + } + if (node.type === AstNodeType.FUNCTION_CALL) { + if (node.procedureName === 'TRUE' && node.args.length === 0) { + return 1 + } + if (node.procedureName === 'FALSE' && node.args.length === 0) { + return 0 + } + } + return undefined + } + + public static implementedFunctions: ImplementedFunctions = { + 'SEQUENCE': { + method: 'sequence', + sizeOfResultArrayMethod: 'sequenceArraySize', + parameters: [ + { argumentType: FunctionArgumentType.NUMBER }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1, emptyAsDefault: true }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1, emptyAsDefault: true }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1, emptyAsDefault: true }, + ], + vectorizationForbidden: true, + }, + } + + /** + * Corresponds to SEQUENCE(rows, [cols], [start], [step]) + * + * Returns a rows×cols array of sequential numbers starting at `start` + * and incrementing by `step`, filled row-major. + * + * Note: dynamic arguments (cell references, formulas) for `rows` or `cols` + * cause a size mismatch between parse-time prediction and runtime result, + * which results in a #VALUE! error. Use literal numbers for rows and cols. + * + * @param {ProcedureAst} ast - The parsed function call AST node. + * @param {InterpreterState} state - Current interpreter evaluation state. + */ + public sequence(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('SEQUENCE'), + (rows: number, cols: number, start: number, step: number) => { + if (!Number.isFinite(rows) || !Number.isFinite(cols)) { + return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge) + } + + if (rows < 0 || cols < 0) { + return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne) + } + + const numRows = Math.trunc(rows) + const numCols = Math.trunc(cols) + + if (!SequencePlugin.isValidDimension(numRows) || !SequencePlugin.isValidDimension(numCols)) { + return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne) + } + + if (numRows > this.config.maxRows || numCols > this.config.maxColumns) { + return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge) + } + + const result: number[][] = [] + for (let r = 0; r < numRows; r++) { + const row: number[] = [] + for (let c = 0; c < numCols; c++) { + row.push(start + (r * numCols + c) * step) + } + result.push(row) + } + + return SimpleRangeValue.onlyNumbers(result) + } + ) + } + + /** + * Predicts the output array size for SEQUENCE at parse time. + * + * Handles NUMBER and STRING literals for rows/cols via `parseLiteralDimension`. + * Non-literal args (cell refs, formulas, unary/binary ops) fall back to 1, + * which will cause a size mismatch at eval time when the actual result is larger. + * + * @param {ProcedureAst} ast - The parsed function call AST node. + * @param {InterpreterState} _state - Current interpreter evaluation state (unused). + */ + public sequenceArraySize(ast: ProcedureAst, _state: InterpreterState): ArraySize { + if (ast.args.length < 1 || ast.args.length > 4) { + return ArraySize.error() + } + + const rowsArg = ast.args[0] + const colsArg = ast.args.length > 1 ? ast.args[1] : undefined + + // Non-literal rows (cell ref, formula, unary/binary op): size unknown at parse time. + // Fall back to scalar so the engine creates a ScalarFormulaVertex instead of an + // ArrayFormulaVertex. The actual evaluation will propagate errors or return #VALUE! + // via the Exporter if the result is larger than 1×1. + if (rowsArg.type === AstNodeType.EMPTY) { + return ArraySize.error() + } + const rows = SequencePlugin.parseLiteralDimension(rowsArg) + if (rows === undefined) { + return ArraySize.error() + } + + const cols = (colsArg === undefined || colsArg.type === AstNodeType.EMPTY) + ? 1 + : SequencePlugin.parseLiteralDimension(colsArg) + if (cols === undefined) { + return ArraySize.error() + } + + if (!SequencePlugin.isValidDimension(rows) || !SequencePlugin.isValidDimension(cols)) { + return ArraySize.error() + } + + if (rows > this.config.maxRows || cols > this.config.maxColumns) { + return ArraySize.error() + } + + return new ArraySize(cols, rows) + } +} diff --git a/src/interpreter/plugin/index.ts b/src/interpreter/plugin/index.ts index 6b79690f0..e2f970d7f 100644 --- a/src/interpreter/plugin/index.ts +++ b/src/interpreter/plugin/index.ts @@ -33,6 +33,7 @@ export {PowerPlugin} from './PowerPlugin' export {RadiansPlugin} from './RadiansPlugin' export {RadixConversionPlugin} from './RadixConversionPlugin' export {RandomPlugin} from './RandomPlugin' +export {SequencePlugin} from './SequencePlugin' export {RoundingPlugin} from './RoundingPlugin' export {SqrtPlugin} from './SqrtPlugin' export {ConditionalAggregationPlugin} from './ConditionalAggregationPlugin' diff --git a/test/fetch-tests.sh b/test/fetch-tests.sh index cbcc5671c..9f585b172 100755 --- a/test/fetch-tests.sh +++ b/test/fetch-tests.sh @@ -35,7 +35,7 @@ git fetch origin if git show-ref --verify --quiet "refs/heads/$CURRENT_BRANCH" || \ git show-ref --verify --quiet "refs/remotes/origin/$CURRENT_BRANCH"; then git checkout "$CURRENT_BRANCH" - git pull # pull latest changes + git pull origin "$CURRENT_BRANCH" # pull latest changes else echo "Branch $CURRENT_BRANCH not found in hyperformula-tests, creating from develop..." git checkout develop