Skip to content

Feature/take function - #1722

Open
Tobiadefami wants to merge 8 commits into
developfrom
feature/take-function
Open

Feature/take function#1722
Tobiadefami wants to merge 8 commits into
developfrom
feature/take-function

Conversation

@Tobiadefami

@Tobiadefami Tobiadefami commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Context

This PR adds support for the TAKE dynamic-array function.

TAKE returns 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 CALC error type and its #CALC! representation in all built-in language packs. For now, #CALC! is produced only by TAKE when a row or column count evaluates to zero, including fractional values truncated to zero and blank-cell references coerced to zero.

Omitting the required rows argument still returns the existing wrong-argument #N/A error.

How did you test your changes?
  • Added 20 focused unit tests for TAKE, covering:
    • Positive and negative row counts.
    • Positive and negative column counts.
    • Optional and syntactically empty argument slots.
    • Counts larger than the source array.
    • Zero row and column counts returning #CALC!.
    • Fractional counts truncated to zero.
    • Blank-cell counts coerced to zero.
    • Invalid argument counts returning #N/A.
    • Error propagation and function metadata.
  • Ran the focused TAKE test suite successfully: 20 tests passed.
  • Ran npm run verify:typings successfully.
  • Ran npm run lint successfully with zero errors.
  • Compared the relevant zero-count and missing-argument behavior with Microsoft Excel.

Types of changes

  • Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore)
  • New feature or improvement (a non-breaking change that adds functionality)
  • Bug fix (a non-breaking change that fixes an issue)
  • Additional language file, or a change to an existing language file (translations)
  • Change to the documentation

Related issues:

  1. Fixes #...

Checklist:

  • I have reviewed the guidelines about Contributing to HyperFormula and I confirm that my code follows the code style of this project.
  • I have signed the Contributor License Agreement.
  • My change is compliant with the OpenDocument standard.
  • My change is compatible with Microsoft Excel.
  • My change is compatible with Google Sheets.
  • I described my changes in the CHANGELOG.md file.
  • My changes require a documentation update.
  • My changes require a migration guide.

Note

Medium Risk
Touches core FunctionPlugin argument validation (behavior change for optionalArg vs defaultValue) and adds a new error type across locales; TAKE is localized array logic with spill prediction but is covered by focused tests per the PR description.

Overview
Adds the Excel-style TAKE dynamic-array function to slice rows/columns from the start or end of a range, with optional columns, syntactically empty counts (via emptyAsDefault + 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 required rows argument still yields #N/A.

Refactors optional-argument handling via shared isFunctionArgumentOptional, so an explicit optionalArg: false can keep a parameter required even when a defaultValue is set (used for TAKE’s rows argument).

Reviewed by Cursor Bugbot for commit b509e68. Bugbot is set up for automated code reviews on this repo. Configure here.

@Tobiadefami
Tobiadefami requested a review from sequba August 5, 2026 13:03
@netlify

netlify Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploy Preview for hyperformula-dev-docs ready!

Name Link
🔨 Latest commit b509e68
🔍 Latest deploy log https://app.netlify.com/projects/hyperformula-dev-docs/deploys/6a7653ab2767500008cddff5
😎 Deploy Preview https://deploy-preview-1722--hyperformula-dev-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
hyperformula-docs b509e68 Aug 07 2026, 09:54 PM

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Performance comparison of head (b509e68) vs base (ebaa2b2)

                                     testName |    base |    head |  change
---------------------------------------------------------------------------
                                      Sheet A |  319.55 |  323.78 |  +1.32%
                                      Sheet B |  101.04 |  111.77 | +10.62%
                                      Sheet T |   89.03 |   97.29 |  +9.28%
                                Column ranges |  465.13 |  490.84 |  +5.53%
                                Sorted lookup | 15060.8 | 16550.4 |  +9.89%
Sheet A:  change value, add/remove row/column |    9.92 |   10.78 |  +8.67%
 Sheet B: change value, add/remove row/column |   84.85 |   97.87 | +15.34%
                   Column ranges - add column |  112.93 |  126.44 | +11.96%
                Column ranges - without batch |  361.75 |  398.14 | +10.06%
                        Column ranges - batch |   93.76 |   96.01 |  +2.40%

Comment thread CHANGELOG.md Outdated

### Added

- Added the `TAKE` dynamic-array function and the `CALC` error type used for empty-array results.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link this pull request in the changelog entry

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The changelog entry now links to #1722.

@Tobiadefami
Tobiadefami requested a review from sequba August 6, 2026 22:44
Comment thread src/interpreter/plugin/ArrayPlugin.ts Outdated
new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)),
)

return new ArraySize(sourceSize.width, sourceSize.height)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 range
  • sourceHeight === 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/interpreter/plugin/ArrayPlugin.ts Outdated
Comment on lines +193 to +194
const rowsIsEmpty = rowsArg?.type === AstNodeType.EMPTY
const columnsIsMissingOrEmpty = columnsArg === undefined || columnsArg.type === AstNodeType.EMPTY

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.EMPTY

and 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."

@Tobiadefami Tobiadefami Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +211 to +213
const result = range.data
.slice(startRow, startRow + rowsToTake)
.map(row => row.slice(startColumn, startColumn + columnsToTake))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 onlyRange for an intermediate it searches, whereas TAKE would be returning it as the formula's result. That result is still materialized downstream by ArrayValue.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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/Cell.ts
*/
export enum ErrorType {
/** Calculation error. */
CALC = 'CALC',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. #CALC! is now included in docs/guide/types-of-errors.md.

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.14815% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 97.33%. Comparing base (ebaa2b2) to head (b509e68).

Files with missing lines Patch % Lines
src/interpreter/plugin/ArrayPlugin.ts 97.77% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             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              
Files with missing lines Coverage Δ
src/Cell.ts 94.44% <100.00%> (+0.05%) ⬆️
src/error-message.ts 100.00% <100.00%> (ø)
src/i18n/languages/csCZ.ts 100.00% <ø> (ø)
src/i18n/languages/daDK.ts 100.00% <ø> (ø)
src/i18n/languages/deDE.ts 100.00% <ø> (ø)
src/i18n/languages/enGB.ts 100.00% <ø> (ø)
src/i18n/languages/esES.ts 100.00% <ø> (ø)
src/i18n/languages/fiFI.ts 100.00% <ø> (ø)
src/i18n/languages/frFR.ts 100.00% <ø> (ø)
src/i18n/languages/huHU.ts 100.00% <ø> (ø)
... and 13 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants