Feature/take function - #1722
Conversation
✅ Deploy Preview for hyperformula-dev-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
hyperformula-docs | b509e68 | Aug 07 2026, 09:54 PM |
Performance comparison of head (b509e68) vs base (ebaa2b2) |
|
|
||
| ### Added | ||
|
|
||
| - Added the `TAKE` dynamic-array function and the `CALC` error type used for empty-array results. |
There was a problem hiding this comment.
Link this pull request in the changelog entry
There was a problem hiding this comment.
Done. The changelog entry now links to #1722.
| new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), | ||
| ) | ||
|
|
||
| return new ArraySize(sourceSize.width, sourceSize.height) |
There was a problem hiding this comment.
takeArraySize always returns the full source dimensions instead of the requested result size, so the engine reserves the source's whole footprint and reports a false #SPILL! for any content that TAKE's real result never touches.
Verified by running the engine:
Sheet1 = [['=TAKE(Data!A1:C3,1)'], [null], ['untouched']] // Data is 3x3
// A1 -> {"value":"#SPILL!","type":"SPILL","message":"No space for array result."}The real 1x3 result never reaches row 3. The control =ARRAY_CONSTRAIN(Data!A1:C3,1,3) in the identical layout correctly returns 1.
The same false collision happens on the width axis — =TAKE(Data!A1:C3,3,1) false-spills on content in B1/C1, so two TAKE formulas placed side by side collide with each other. Also getSheetDimensions reports height 3 for a 1-row result, and isCellPartOfArray(C3) is true.
arrayconstrainArraySize (lines 105-124 of this file) already solves exactly this by inspecting literal AstNodeType.NUMBER args:
if (ast.args[1].type === AstNodeType.NUMBER) { height = Math.min(height, ast.args[1].value) }SequencePlugin's parseLiteralDimension does the same.
On the parse-time constraint
This can't be fixed in full, and that's worth stating explicitly: HyperFormula predicts array sizes statically, before evaluation, so whenever rows/columns come from a cell reference or a nested formula the result size genuinely isn't knowable at prediction time and an upper bound is the only option. That's the same unavoidable situation FILTER and UNIQUE are in, where the size is data-dependent by nature.
TAKE differs from those in one respect: its result size is statically known whenever the counts are numeric literals — the common case, and the form used in TAKE's own documented examples (=TAKE(A1:C5, 2)). Tightening the prediction for literal counts, the way ARRAY_CONSTRAIN and SEQUENCE already do, would remove the false spill for that majority case and leave the upper-bound reservation only where it is genuinely unavoidable.
There was a problem hiding this comment.
Fixed. takeArraySize() now uses literal row and column counts, including negative literals, to predict the result footprint.
Dynamic counts still use the source size as an upper bound.
| const requestedColumns = columnsIsMissingOrEmpty || columns === undefined ? sourceWidth : Math.trunc(columns) | ||
|
|
||
| if (requestedRows === 0 || requestedColumns === 0 || sourceHeight === 0 || sourceWidth === 0) { | ||
| return new CellError(ErrorType.CALC, ErrorMessage.EmptyRange) |
There was a problem hiding this comment.
The zero-count branch returns ErrorMessage.EmptyRange ("Empty range not allowed."), which is factually wrong when the source range is fully populated and only the requested count is zero — and the new spec locks that wrong text in.
=TAKE(A1:C3, 0) // over a fully populated 3x3 range
// -> {type: 'CALC', value: '#CALC!', message: 'Empty range not allowed.'}This is user-visible through DetailedCellError.message, which is what getCellValue() returns. The range is not empty; the count argument is.
The condition on line 203 conflates two distinct situations under one message that only fits the second:
requestedRows === 0 || requestedColumns === 0— a zero count against a perfectly good rangesourceHeight === 0 || sourceWidth === 0— a genuinely empty source
function-take.spec.ts:110/120/130/141/152 assert the wrong wording, so it is pinned in place by the tests.
ErrorMessage.LessThanOne ("Argument cannot be less than 1.") is closer but also imprecise, since negative counts are legal for TAKE. A new message along the lines of "Row or column count cannot be zero." would fit, keeping EmptyRange for the genuinely-empty-source branch.
There was a problem hiding this comment.
Fixed. A zero row or column count now returns #CALC! with “Row or column count cannot be zero.”
EmptyRange is kept for a genuinely zero-dimensional source.
| const rowsIsEmpty = rowsArg?.type === AstNodeType.EMPTY | ||
| const columnsIsMissingOrEmpty = columnsArg === undefined || columnsArg.type === AstNodeType.EMPTY |
There was a problem hiding this comment.
take hand-inspects ast.args for AstNodeType.EMPTY to implement "empty argument keeps everything", bypassing the argument-metadata mechanism the framework already provides and that every comparable function in the codebase uses.
These two lines read the AST directly, then lines 200-201 branch on the captured flags inside the callback.
FunctionPlugin.ts:236-249 documents emptyAsDefault as: "an empty argument is treated as if the argument was not provided at all" — that is, it falls back to defaultValue. It is used by SequencePlugin.ts:72-74, SortPlugin.ts:33-35, UniquePlugin.ts:32-33, and — the exact optionalArg + emptyAsDefault combination TAKE needs — AddressPlugin.ts:29-30.
The duplication is literal. FunctionPlugin.ts:302 already computes:
const isSyntacticallyEmpty = argAst.type === AstNodeType.EMPTYand threads it through as syntacticallyEmptyFlags into coerceArgumentsToRequiredTypes, which is the same expression these two lines recompute by hand.
Declaring rows as {defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true} (and likewise for columns) removes both flags and both branches, since the existing Math.min(Math.abs(...), sourceHeight) clamp already collapses Infinity down to the source dimension. coerceToType applies minValue/maxValue only when they are declared, and TAKE declares neither, so Infinity passes through the coercion layer untouched.
Cost: two mechanisms now express "empty means keep the default" in one class, against DEV_DOCS.md:94 — "Avoid duplication. Extract shared logic instead of copying it, and reuse the existing helpers and abstractions of the codebase."
There was a problem hiding this comment.
Fixed. TAKE no longer checks EMPTY AST nodes itself. Both count arguments now use defaultValue: Infinity with emptyAsDefault: true.
This also required separating defaults from optionality. The rows argument needs a default for TAKE(range,,2), but it must remain required for TAKE(range).
An explicit optionalArg: false now takes precedence over the presence of a default.
| const result = range.data | ||
| .slice(startRow, startRow + rowsToTake) | ||
| .map(row => row.slice(startColumn, startColumn + columnsToTake)) |
There was a problem hiding this comment.
range.data forces materialization of the entire source range before the slices discard almost all of it, making the cost O(source area) rather than O(result area).
=TAKE(A1:C100000, 2) reads and boxes all 300,000 cells to keep 6: range.data calls SimpleRangeValue.ensureThatComputed (SimpleRangeValue.ts:211-224), which runs addressesArrayMap over the whole range, and only then do these lines slice.
A narrower path already exists in this codebase for the same shape of problem. LookupPlugin's doVlookup/doHlookup narrow first:
searchedRange = SimpleRangeValue.onlyRange(AbsoluteCellRange.spanFrom(range.start, 1, range.height()), this.dependencyGraph)(LookupPlugin.ts:219 and :249). SimpleRangeValue.onlyRange (SimpleRangeValue.ts:74-76) constructs with _data = undefined, so materialization is deferred to just that sub-range. When range.range !== undefined, TAKE could span the target sub-range directly instead of slicing an already-materialized 2D array; the computed-array case (=TAKE(SEQUENCE(3,3),2,2), where range.range is undefined) would still need the present path.
Two caveats on the suggestion, so it is not taken as drop-in:
- LookupPlugin uses
onlyRangefor an intermediate it searches, whereas TAKE would be returning it as the formula's result. That result is still materialized downstream byArrayValue.fromInterpreterValue— but over the sub-range only, which is the intended win. Worth confirming the returned-value path behaves. - For negative counts the span has to start from an offset address rather than
range.start.
ARRAY_CONSTRAIN carries the same cost, so this is not a regression introduced here. But DEV_DOCS.md:103-104 asks to "Consider the computational complexity of every change" and to "Pick the best complexity that still keeps the code readable. When a faster algorithm is harder to follow, explain the trade-off in a JSDoc comment." take's JSDoc has neither the tighter approach nor a note on why it was not taken.
There was a problem hiding this comment.
Fixed. Address-backed inputs now return a narrowed onlyRange covering the TAKE result.
Computed arrays keep the existing value-slicing path.
Negative row and column offsets are handled independently.
| */ | ||
| export enum ErrorType { | ||
| /** Calculation error. */ | ||
| CALC = 'CALC', |
There was a problem hiding this comment.
The hand-maintained error reference table was not updated with this new error type, which DEV_DOCS.md's definition of done requires.
docs/guide/types-of-errors.md lists exactly 9 rows — #DIV/0!, #N/A, #NAME?, #NUM!, #REF!, #VALUE!, #CYCLE!, #ERROR!, #LIC! — and stops at line 26. grep -rn CALC docs/guide/*.md matches only the unrelated SEQUENCE(0) row in list-of-differences.md.
DEV_DOCS.md:74 lists "Updates to documentation related to the change" among the definition-of-done items, and DOCS_CONTENT_GUIDE.md:5-6 states the core principle: "Every page and every section is retrieved and read in isolation, out of order, by a reader with zero prior context."
A user who sees #CALC! from TAKE and opens the page that exists to explain error values finds nothing.
There was a problem hiding this comment.
Done. #CALC! is now included in docs/guide/types-of-errors.md.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #1722 +/- ##
===========================================
+ Coverage 97.31% 97.33% +0.02%
===========================================
Files 195 195
Lines 15734 15785 +51
Branches 3456 3470 +14
===========================================
+ Hits 15312 15365 +53
+ Misses 414 412 -2
Partials 8 8
🚀 New features to boost your workflow:
|
Context
This PR adds support for the
TAKEdynamic-array function.TAKEreturns a specified number of rows or columns from the beginning or end of an array. The implementation supports positive and negative counts, optional columns, syntactically empty argument slots, array spilling, and function metadata.It also introduces the
CALCerror type and its#CALC!representation in all built-in language packs. For now,#CALC!is produced only byTAKEwhen a row or column count evaluates to zero, including fractional values truncated to zero and blank-cell references coerced to zero.Omitting the required
rowsargument still returns the existing wrong-argument#N/Aerror.How did you test your changes?
TAKE, covering:#CALC!.#N/A.TAKEtest suite successfully: 20 tests passed.npm run verify:typingssuccessfully.npm run lintsuccessfully with zero errors.Types of changes
Related issues:
Checklist:
Note
Medium Risk
Touches core
FunctionPluginargument validation (behavior change foroptionalArgvsdefaultValue) and adds a new error type across locales;TAKEis localized array logic with spill prediction but is covered by focused tests per the PR description.Overview
Adds the Excel-style
TAKEdynamic-array function to slice rows/columns from the start or end of a range, with optionalcolumns, syntactically empty counts (viaemptyAsDefault+ infinity defaults), spill sizing, and lazy sub-range results when the source is address-backed.Introduces
ErrorType.CALC/#CALC!(documented and localized) for invalid array results—currently when row or column counts truncate to zero or the source range is empty. Omitting the requiredrowsargument still yields#N/A.Refactors optional-argument handling via shared
isFunctionArgumentOptional, so an explicitoptionalArg: falsecan keep a parameter required even when adefaultValueis set (used forTAKE’srowsargument).Reviewed by Cursor Bugbot for commit b509e68. Bugbot is set up for automated code reviews on this repo. Configure here.