From 1fd3ca0f2655dcc28ecf195261c4699a185c163c Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Thu, 19 Feb 2026 12:45:55 +0100 Subject: [PATCH 01/20] Fix package-lock file --- package-lock.json | 224 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) diff --git a/package-lock.json b/package-lock.json index f2d4c6f11..716278366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10062,6 +10062,34 @@ "esbuild-windows-arm64": "0.14.7" } }, + "node_modules/esbuild-android-arm64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.7.tgz", + "integrity": "sha512-9/Q1NC4JErvsXzJKti0NHt+vzKjZOgPIjX/e6kkuCzgfT/GcO3FVBcGIv4HeJG7oMznE6KyKhvLrFgt7CdU2/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/esbuild-darwin-64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.7.tgz", + "integrity": "sha512-Z9X+3TT/Xj+JiZTVlwHj2P+8GoiSmUnGVz0YZTSt8WTbW3UKw5Pw2ucuJ8VzbD2FPy0jbIKJkko/6CMTQchShQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, "node_modules/esbuild-darwin-arm64": { "version": "0.14.7", "cpu": [ @@ -10074,6 +10102,202 @@ "darwin" ] }, + "node_modules/esbuild-freebsd-64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.7.tgz", + "integrity": "sha512-76zy5jAjPiXX/S3UvRgG85Bb0wy0zv/J2lel3KtHi4V7GUTBfhNUPt0E5bpSXJ6yMT7iThhnA5rOn+IJiUcslQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.7.tgz", + "integrity": "sha512-lSlYNLiqyzd7qCN5CEOmLxn7MhnGHPcu5KuUYOG1i+t5A6q7LgBmfYC9ZHJBoYyow3u4CNu79AWHbvVLpE/VQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/esbuild-linux-32": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.7.tgz", + "integrity": "sha512-Vk28u409wVOXqTaT6ek0TnfQG4Ty1aWWfiysIaIRERkNLhzLhUf4i+qJBN8mMuGTYOkE40F0Wkbp6m+IidOp2A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.7.tgz", + "integrity": "sha512-+Lvz6x+8OkRk3K2RtZwO+0a92jy9si9cUea5Zoru4yJ/6EQm9ENX5seZE0X9DTwk1dxJbjmLsJsd3IoowyzgVg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-arm": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.7.tgz", + "integrity": "sha512-OzpXEBogbYdcBqE4uKynuSn5YSetCvK03Qv1HcOY1VN6HmReuatjJ21dCH+YPHSpMEF0afVCnNfffvsGEkxGJQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.7.tgz", + "integrity": "sha512-kJd5beWSqteSAW086qzCEsH6uwpi7QRIpzYWHzEYwKKu9DiG1TwIBegQJmLpPsLp4v5RAFjea0JAmAtpGtRpqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.7.tgz", + "integrity": "sha512-mFWpnDhZJmj/h7pxqn1GGDsKwRfqtV7fx6kTF5pr4PfXe8pIaTERpwcKkoCwZUkWAOmUEjMIUAvFM72A6hMZnA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.7.tgz", + "integrity": "sha512-wM7f4M0bsQXfDL4JbbYD0wsr8cC8KaQ3RPWc/fV27KdErPW7YsqshZZSjDV0kbhzwpNNdhLItfbaRT8OE8OaKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.7.tgz", + "integrity": "sha512-J/afS7woKyzGgAL5FlgvMyqgt5wQ597lgsT+xc2yJ9/7BIyezeXutXqfh05vszy2k3kSvhLesugsxIA71WsqBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ] + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.7.tgz", + "integrity": "sha512-7CcxgdlCD+zAPyveKoznbgr3i0Wnh0L8BDGRCjE/5UGkm5P/NQko51tuIDaYof8zbmXjjl0OIt9lSo4W7I8mrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/esbuild-sunos-64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.7.tgz", + "integrity": "sha512-GKCafP2j/KUljVC3nesw1wLFSZktb2FGCmoT1+730zIF5O6hNroo0bSEofm6ZK5mNPnLiSaiLyRB9YFgtkd5Xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ] + }, + "node_modules/esbuild-windows-32": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.7.tgz", + "integrity": "sha512-5I1GeL/gZoUUdTPA0ws54bpYdtyeA2t6MNISalsHpY269zK8Jia/AXB3ta/KcDHv2SvNwabpImeIPXC/k0YW6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/esbuild-windows-64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.7.tgz", + "integrity": "sha512-CIGKCFpQOSlYsLMbxt8JjxxvVw9MlF1Rz2ABLVfFyHUF5OeqHD5fPhGrCVNaVrhO8Xrm+yFmtjcZudUGr5/WYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.7.tgz", + "integrity": "sha512-eOs1eSivOqN7cFiRIukEruWhaCf75V0N8P0zP7dh44LIhLl8y6/z++vv9qQVbkBm5/D7M7LfCfCTmt1f1wHOCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/escalade": { "version": "3.2.0", "dev": true, From 7616150ccb31f0169e0f6faf4d9c96d9b420719f Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Fri, 20 Feb 2026 13:07:02 +0100 Subject: [PATCH 02/20] Docs: remove CodeSandbox embedded demos and add links to working exa,ples in Stackblitz (#1621) --- docs/guide/custom-functions.md | 10 ++-------- docs/guide/integration-with-angular.md | 8 +------- docs/guide/integration-with-react.md | 8 +------- docs/guide/integration-with-svelte.md | 8 +------- docs/guide/integration-with-vue.md | 10 ++-------- 5 files changed, 7 insertions(+), 37 deletions(-) diff --git a/docs/guide/custom-functions.md b/docs/guide/custom-functions.md index d78408e61..10d1db153 100644 --- a/docs/guide/custom-functions.md +++ b/docs/guide/custom-functions.md @@ -358,18 +358,12 @@ it('returns a VALUE error if the range argument contains a string', () => { ## Working demo +Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/custom-functions?v=${$page.buildDateURIEncoded}). + This demo contains the implementation of both the [`GREET`](#add-a-simple-custom-function) and [`DOUBLE_RANGE`](#advanced-custom-function-example) custom functions. - - ## Function options You can set the following options for your function: diff --git a/docs/guide/integration-with-angular.md b/docs/guide/integration-with-angular.md index 1991fe3fb..8f78e2097 100644 --- a/docs/guide/integration-with-angular.md +++ b/docs/guide/integration-with-angular.md @@ -6,10 +6,4 @@ For more details, see the [client-side installation](client-side-installation.md ## Demo - +Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/angular-demo?v=${$page.buildDateURIEncoded}). diff --git a/docs/guide/integration-with-react.md b/docs/guide/integration-with-react.md index 75b4c64e7..d4bc7fe75 100644 --- a/docs/guide/integration-with-react.md +++ b/docs/guide/integration-with-react.md @@ -6,10 +6,4 @@ For more details, see the [client-side installation](client-side-installation.md ## Demo - +Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/react-demo?v=${$page.buildDateURIEncoded}). diff --git a/docs/guide/integration-with-svelte.md b/docs/guide/integration-with-svelte.md index 310fc8823..8b3a5f4b6 100644 --- a/docs/guide/integration-with-svelte.md +++ b/docs/guide/integration-with-svelte.md @@ -6,10 +6,4 @@ For more details, see the [client-side installation](client-side-installation.md ## Demo - +Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/svelte-demo?v=${$page.buildDateURIEncoded}). diff --git a/docs/guide/integration-with-vue.md b/docs/guide/integration-with-vue.md index 5307780c6..eaa104e0e 100644 --- a/docs/guide/integration-with-vue.md +++ b/docs/guide/integration-with-vue.md @@ -31,14 +31,8 @@ This function prevents Vue from converting the HyperFormula instance into a reac ## Demo +Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/vue-3-demo?v=${$page.buildDateURIEncoded}). + ::: tip This demo uses the [Vue 3](https://v3.vuejs.org/) framework. If you are looking for an example using Vue 2, check out the [code on GitHub](https://github.com/handsontable/hyperformula-demos/tree/2.5.x/vue-demo). ::: - - From c96a600d8769c9021096e72e7bb4bcc8139633c1 Mon Sep 17 00:00:00 2001 From: Mateusz Wojczal Date: Wed, 4 Mar 2026 13:12:26 +0100 Subject: [PATCH 03/20] Setup workflow with private tests repo (#1616) * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Extend CI For private tests * Add npm script test:fetch-private * Adjust eslintingore * Adjust test.yml GH workflow * Add performance.yml GH workflow * Setup codecov.yml * Remove Makefile * Bring back removed npm scripts * Update test/README.md * Add setup files for jest and karma * Move codecov.yml to the repository root * Fix typo in eslintignore file --------- Co-authored-by: Kuba Sekowski --- .eslintignore | 1 + .github/workflows/audit.yml | 7 ++- .github/workflows/build-docs.yml | 10 ++-- .github/workflows/build.yml | 10 ++-- .github/workflows/lint.yml | 10 ++-- .github/workflows/performance.yml | 76 +++++++++++++++++++++++++++++++ .github/workflows/publish.yml | 10 ++-- .github/workflows/test.yml | 48 ++++++++++++++----- .gitignore | 3 ++ Makefile | 63 ------------------------- codecov.yml | 14 ++++++ package.json | 14 +++++- test/README.md | 51 +++++++++++++++++++++ test/_setupFiles/bootstrap.ts | 7 ++- test/fetch-tests.sh | 43 +++++++++++++++++ test/smoke.spec.ts | 19 +++++++- test/testUtils.ts | 25 ---------- 17 files changed, 280 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/performance.yml delete mode 100644 Makefile create mode 100644 codecov.yml create mode 100755 test/fetch-tests.sh delete mode 100644 test/testUtils.ts diff --git a/.eslintignore b/.eslintignore index ea6f691b2..03546876e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -15,6 +15,7 @@ test/_setupFiles/*.js # Auto-generated directories commonjs +coverage dist doc es diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index b4c0826dd..27bb18622 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -23,13 +23,12 @@ jobs: name: audit runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 - - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d # https://github.com/actions/setup-node/releases/tag/v1.4.4 with: node-version: ${{ matrix.node-version }} + - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 + - name: Run audit - run: | - npm run audit + run: npm run audit diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 526211df2..60c43dfc3 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -23,17 +23,15 @@ jobs: name: build-docs runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 - - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d # https://github.com/actions/setup-node/releases/tag/v1.4.4 with: node-version: ${{ matrix.node-version }} + - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 + - name: Install dependencies - run: | - npm ci + run: npm ci - name: Build docs - run: | - npm run docs:build + run: npm run docs:build diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a0b584a2..13313f8a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,17 +25,15 @@ jobs: install-command: [ i, ci ] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 - - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d # https://github.com/actions/setup-node/releases/tag/v1.4.4 with: node-version: ${{ matrix.node-version }} + - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 + - name: Install dependencies - run: | - npm ${{ matrix.install-command }} + run: npm ${{ matrix.install-command }} - name: Build - run: | - npm run bundle-all + run: npm run bundle-all diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8d10020c2..015fbcdc1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,17 +23,15 @@ jobs: name: lint runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 - - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d # https://github.com/actions/setup-node/releases/tag/v1.4.4 with: node-version: ${{ matrix.node-version }} + - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 + - name: Install dependencies - run: | - npm ci + run: npm ci - name: Run linter - run: | - npm run lint + run: npm run lint diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 000000000..9ef3c8190 --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,76 @@ +name: Performance +permissions: + contents: read + pull-requests: write + +on: + pull_request: + types: + - opened + - reopened + - synchronize # the head branch is updated from the base branch, new commits are pushed to the head branch, or the base branch is changed + +jobs: + performance-test: + strategy: + matrix: + node-version: [ '22' ] + os: [ 'ubuntu-latest' ] + name: Test performance + runs-on: ${{ matrix.os }} + steps: + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d # https://github.com/actions/setup-node/releases/tag/v1.4.4 + with: + node-version: ${{ matrix.node-version }} + + - name: (base) Checkout main repository + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f # https://github.com/actions/checkout/releases/tag/v2.3.4 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - name: Checkout hyperformula-tests repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ssh-key: ${{ secrets.DEPLOY_TOKEN }} + repository: handsontable/hyperformula-tests + path: test/hyperformula-tests + + - name: Fetch hyperformula-tests and sync branches + run: cd test && ./fetch-tests.sh + + - name: (base) Install dependencies + run: npm ci + + - name: (base) Run performance tests + run: npm run benchmark:write-to-file base.json + + - name: (head) Checkout main repository + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f # https://github.com/actions/checkout/releases/tag/v2.3.4 + with: + clean: false + + - name: (head) Install dependencies + run: npm ci + + - name: (head) Run performance tests + run: | + npm run benchmark:write-to-file head.json + + - name: Compare the results + run: | + npm run benchmark:compare-benchmarks base.json head.json performance-report.md + + - name: Publish a comment - header + uses: marocchino/sticky-pull-request-comment@6804b5ad49d19c10c9ae7cf5057352f7ff333f31 # https://github.com/marocchino/sticky-pull-request-comment/tree/v1.6.0 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + message: | + ## Performance comparison of head (${{ github.event.pull_request.head.sha }}) vs base (${{ github.event.pull_request.base.sha }}) + + - name: Publish a comment - performance comparison report + uses: marocchino/sticky-pull-request-comment@6804b5ad49d19c10c9ae7cf5057352f7ff333f31 # https://github.com/marocchino/sticky-pull-request-comment/tree/v1.6.0 + with: + append: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + path: performance-report.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index acc67e571..c87e1f36d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,20 +17,18 @@ jobs: name: publish-docs runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 - - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d # https://github.com/actions/setup-node/releases/tag/v1.4.4 with: node-version: ${{ matrix.node-version }} + - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 + - name: Install dependencies - run: | - npm ci + run: npm ci - name: Build docs - run: | - npm run docs:build + run: npm run docs:build - name: Deploy to GH pages uses: peaceiris/actions-gh-pages@ba0b7df03e25ff29c924be8149041119e9421ea6 # https://github.com/peaceiris/actions-gh-pages/releases/tag/v3.5.6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c289fd5b7..9486ae925 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,20 +23,35 @@ jobs: name: unit-tests runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 - - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d # https://github.com/actions/setup-node/releases/tag/v1.4.4 with: node-version: ${{ matrix.node-version }} + - name: Checkout main repository + uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 + + - name: Checkout hyperformula-tests repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ssh-key: ${{ secrets.DEPLOY_TOKEN }} + repository: handsontable/hyperformula-tests + path: test/hyperformula-tests + + - name: Fetch hyperformula-tests and sync branches + run: cd test && ./fetch-tests.sh + - name: Install dependencies - run: | - npm ci + run: npm ci - name: Run tests - run: | - npm run test:ci + run: npm run test:ci -- --coverage + + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@6004246f47ab62d32be025ce173b241cd84ac58e # https://github.com/codecov/codecov-action/releases/tag/v1.0.13 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} browser-tests: strategy: @@ -46,17 +61,26 @@ jobs: name: browser-tests runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 - - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d # https://github.com/actions/setup-node/releases/tag/v1.4.4 with: node-version: ${{ matrix.node-version }} + - name: Checkout main repository + uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 # https://github.com/actions/checkout/releases/tag/v2.0.0 + + - name: Checkout hyperformula-tests repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ssh-key: ${{ secrets.DEPLOY_TOKEN }} + repository: handsontable/hyperformula-tests + path: test/hyperformula-tests + + - name: Fetch hyperformula-tests and sync branches + run: cd test && ./fetch-tests.sh + - name: Install dependencies - run: | - npm ci + run: npm ci - name: Run tests - run: | - npm run test:browser + run: npm run test:browser diff --git a/.gitignore b/.gitignore index 5d6c7e54a..98181e71c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea/ .vscode /commonjs/ +/coverage/ /dist/ /doc/ /docs/api/ @@ -21,3 +22,5 @@ node_modules/ *.iml dev*.html .DS_Store + +/test/hyperformula-tests/ diff --git a/Makefile b/Makefile deleted file mode 100644 index 5262600c8..000000000 --- a/Makefile +++ /dev/null @@ -1,63 +0,0 @@ -typecheck: ## Typecheck the code - @npm run verify:typings - -setup: ## Setup project - @npm i - -compile: ## Compile to javascript - @npm run compile - -check: typecheck lint ## Check whether code is working correctly (types + lint) - -test-ci: ## Separate test configuration for CI environment - @npm run test - -check: typecheck test ## Check whether code is working correctly (types + specs) - -full: check lint-fix ## Check whether code is ready to commit (types + specs + lint) - -lint: ## Show linting errors - @npm run lint - -lint-fix: ## Fix linting errors - @npm run lint:fix - -doc: ## Generate documentation - @npm run typedoc:build - -servedoc: ## Run server with documentation - @npm run typedoc:serve - -clean: ## Clean compiled files - @npm run clean - -bundle: - @npm run bundle-all - -bundle-es: compile ## Transpiles files to ES - @npm run bundle:es - -bundle-commonjs: compile ## Transpiles files to CommonJS - @npm run bundle:cjs - -bundle-development: compile ## Transpiles and bundles files to UMD format (without minification) - @npm run bundle:development - -bundle-production: compile ## Transpiles and bundles files to UMD format (with minification) - @npm run bundle:production - -bundle-typings: ## Generates TypeScript declaration files - @npm run bundle:typings - -check-bundle: - @npm run verify-bundles - -verify-production-licenses: - @npm run check:licenses - -help: ## Show all make commands - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: test doc servedoc - -.DEFAULT_GOAL := help diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..acc230a50 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,14 @@ +codecov: + require_ci_to_pass: yes + +coverage: + range: 95..100 + round: down + precision: 2 + +comment: + layout: "reach, diff, flags, files" + behavior: new + require_changes: false + require_base: yes + require_head: yes diff --git a/package.json b/package.json index 81ef6ab80..ff4399c57 100644 --- a/package.json +++ b/package.json @@ -76,14 +76,26 @@ "verify:publish-package": "npm pack | node script/check-publish-package.js", "verify:typings": "tsc --noEmit", "test": "npm-run-all lint test:jest test:browser", + "test:fetch-private": "bash test/fetch-tests.sh", "test:jest": "cross-env NODE_ICU_DATA=node_modules/full-icu jest", + "test:watch": "npm run test:jest -- --watch", + "test:tdd": "npm run test:jest -- --watch function-value", + "test:coverage": "npm run test:jest -- --coverage", + "test:logMemory": "npm run test:jest -- --runInBand --logHeapUsage", + "test:performance": "npm run benchmark:basic && npm run benchmark:cruds", + "test:compatibility": "bash test/compatibility/test-compatibility.sh", "test:ci": "cross-env NODE_ICU_DATA=node_modules/full-icu node --expose-gc ./node_modules/jest/bin/jest --forceExit", "test:browser": "cross-env-shell BABEL_ENV=dist env-cmd -f ht.config.js karma start", + "test:browser.debug": "cross-env-shell BABEL_ENV=dist NODE_ENV=debug env-cmd -f ht.config.js karma start", "typedoc:build-api": "cross-env NODE_OPTIONS=--openssl-legacy-provider typedoc --options .typedoc.md.ts", + "benchmark:basic": "npm run tsnode test/performance/run-basic-benchmark.ts", + "benchmark:cruds": "npm run tsnode test/performance/run-cruds-benchmark.ts", + "benchmark:write-to-file": "npm run tsnode test/performance/write-to-file.ts", + "benchmark:compare-benchmarks": "npm run tsnode test/performance/compare-benchmarks.ts", "lint": "eslint . --ext .js,.ts", "lint:fix": "eslint . --ext .js,.ts --fix", "audit": "npm audit --omit=dev", - "clean": "rimraf commonjs/ dist/ es/ languages/ lib/ typings/ test-jasmine/", + "clean": "rimraf coverage/ commonjs/ dist/ es/ languages/ lib/ typings/ test-jasmine/", "compile": "tsc", "check:licenses": "license-checker --production --excludePackages=\"hyperformula@3.1.1\" --onlyAllow=\"MIT; Apache-2.0; BSD-3-Clause; BSD-2-Clause; ISC; BSD; Unlicense\"", "tsnode": "ts-node --transpile-only -O {\\\"module\\\":\\\"commonjs\\\"}" diff --git a/test/README.md b/test/README.md index 858c21303..a1347926b 100644 --- a/test/README.md +++ b/test/README.md @@ -5,3 +5,54 @@ This folder contains only simple smoke tests that verify core HyperFormula funct HyperFormula team maintains a comprehensive test suite that includes unit tests run in different environments, compatibility tests, and performance tests. The full test suite is available on request. To obtain it, contact us at hyperformula@handsontable.com. + +## Using the private test suite + +The private test suite is kept in the `hyperformula-tests` repository. Once you have access to it, you can use it following this instructions: + +Whenenever you switch branch in the main repository, you need to fetch the private test suite to the `test/hyperformula-tests` directory by running: +``` +npm run test:fetch-private + +``` + +Then, you can run the test by calling one og the commands: + +``` +npm run test +npm run test:jest +npm run test:browser +npm run test:coverage +npm run test:performance +npm run test:compatibility +``` + +## `fetch-tests.sh` + +This file is located in the `test` directory. + +### What it does + +1. **Clone if missing** – If `test/hyperformula-tests` does not exist: + - With `DEPLOY_TOKEN` set: clones via HTTPS using the token + - Otherwise: clones via SSH (`git@github.com:handsontable/hyperformula-tests.git`) + +2. **Detect current branch** – Uses: + - `GITHUB_HEAD_REF` (pull request source branch) + - `GITHUB_REF_NAME` (push branch) + - `git rev-parse --abbrev-ref HEAD` (local), falling back to `develop` on detached HEAD + +3. **Checkout matching branch** – In `hyperformula-tests`: + - Fetches from `origin` + - Checks out the branch if it exists locally or on `origin` + - Otherwise checks out `develop` + +4. **Pull latest** – Runs `git pull origin` on the checked-out branch. + +### Environment variables + +| Variable | Purpose | +|-----------------|-------------------------------------------------------------------------| +| `DEPLOY_TOKEN` | GitHub token for HTTPS clone (used in CI when SSH is not available) | +| `GITHUB_HEAD_REF` | Source branch for pull requests (set by GitHub Actions) | +| `GITHUB_REF_NAME` | Branch/tag that triggered the workflow (set by GitHub Actions) | diff --git a/test/_setupFiles/bootstrap.ts b/test/_setupFiles/bootstrap.ts index 1477d78c6..7cdbd1946 100644 --- a/test/_setupFiles/bootstrap.ts +++ b/test/_setupFiles/bootstrap.ts @@ -6,9 +6,14 @@ import {HyperFormula} from '../../src' import {Config} from '../../src/Config' import {enGB} from '../../src/i18n/languages' import * as plugins from '../../src/interpreter/plugin' -import {unregisterAllLanguages} from '../testUtils' import {toContainEqualMatcher, toEqualErrorMatcher, toMatchObjectMatcher} from './matchers' +function unregisterAllLanguages() { + for (const langCode of HyperFormula.getRegisteredLanguagesCodes()) { + HyperFormula.unregisterLanguage(langCode) + } +} + Config.defaultConfig = Object.assign({}, Config.defaultConfig, { functionPlugins: [], useStats: true, diff --git a/test/fetch-tests.sh b/test/fetch-tests.sh new file mode 100755 index 000000000..932e7e18c --- /dev/null +++ b/test/fetch-tests.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +HYPERFORMULA_TESTS_DIR="$SCRIPT_DIR/hyperformula-tests" + +# 1. Check if hyperformula-tests exists +if [ ! -d "$HYPERFORMULA_TESTS_DIR" ]; then + echo "Cloning hyperformula-tests..." + if [ -n "$DEPLOY_TOKEN" ]; then + git clone "https://x-access-token:${DEPLOY_TOKEN}@github.com/handsontable/hyperformula-tests.git" "$HYPERFORMULA_TESTS_DIR" + else + git clone git@github.com:handsontable/hyperformula-tests.git "$HYPERFORMULA_TESTS_DIR" + fi +fi + +# 2. Get current branch from root repo (GitHub Actions uses detached HEAD, so use env vars in CI) +cd "$REPO_ROOT" +if [ -n "$GITHUB_HEAD_REF" ]; then + CURRENT_BRANCH="$GITHUB_HEAD_REF" +elif [ -n "$GITHUB_REF_NAME" ] && [[ "$GITHUB_REF_NAME" != *"/merge" ]]; then + CURRENT_BRANCH="$GITHUB_REF_NAME" +else + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + [ "$CURRENT_BRANCH" = "HEAD" ] && CURRENT_BRANCH="develop" +fi + +echo "Checking out branch $CURRENT_BRANCH in hyperformula-tests..." + +# 3. Checkout matching branch in hyperformula-tests or fall back to develop +cd "$HYPERFORMULA_TESTS_DIR" +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" +else + git checkout develop +fi + +# 4. Pull changes from origin +git pull origin "$(git rev-parse --abbrev-ref HEAD)" diff --git a/test/smoke.spec.ts b/test/smoke.spec.ts index 1e520c1b9..28108ccbd 100644 --- a/test/smoke.spec.ts +++ b/test/smoke.spec.ts @@ -1,5 +1,22 @@ import {HyperFormula} from '../src' -import {adr} from './testUtils' +import {SimpleCellAddress, simpleCellAddress} from '../src/Cell' + +const adr = (stringAddress: string, sheet: number = 0): SimpleCellAddress => { + + const result = /^(\$([A-Za-z0-9_]+)\.)?(\$?)([A-Za-z]+)(\$?)([0-9]+)$/.exec(stringAddress)! + const row = Number(result[6]) - 1 + return simpleCellAddress(sheet, colNumber(result[4]), row) +} + +const colNumber = (input: string): number => { + if (input.length === 1) { + return input.toUpperCase().charCodeAt(0) - 65 + } else { + return input.split('').reduce((currentColumn, nextLetter) => { + return currentColumn * 26 + (nextLetter.toUpperCase().charCodeAt(0) - 64) + }, 0) - 1 + } +} describe('HyperFormula', () => { it('should build engine from array and evaluate formulas', () => { diff --git a/test/testUtils.ts b/test/testUtils.ts deleted file mode 100644 index 59977ddc8..000000000 --- a/test/testUtils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {HyperFormula} from '../src' -import {SimpleCellAddress, simpleCellAddress} from '../src/Cell' - -export const adr = (stringAddress: string, sheet: number = 0): SimpleCellAddress => { - - const result = /^(\$([A-Za-z0-9_]+)\.)?(\$?)([A-Za-z]+)(\$?)([0-9]+)$/.exec(stringAddress)! - const row = Number(result[6]) - 1 - return simpleCellAddress(sheet, colNumber(result[4]), row) -} - -const colNumber = (input: string): number => { - if (input.length === 1) { - return input.toUpperCase().charCodeAt(0) - 65 - } else { - return input.split('').reduce((currentColumn, nextLetter) => { - return currentColumn * 26 + (nextLetter.toUpperCase().charCodeAt(0) - 64) - }, 0) - 1 - } -} - -export function unregisterAllLanguages() { - for (const langCode of HyperFormula.getRegisteredLanguagesCodes()) { - HyperFormula.unregisterLanguage(langCode) - } -} From 4042b04263c9b604c8c0bd0a71dfe2013b42bd3c Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Thu, 5 Mar 2026 07:36:22 +0100 Subject: [PATCH 04/20] Fix IRR error when income > sum of payments (#1627) ### Context The IRR function returns `#NUM!` error when the initial investment significantly exceeds the sum of returns (e.g., `=IRR({-150000, 12000, 15000, 18000})`). Excel correctly returns ~-41% for this case. **Root cause:** The `irrCore` Newton-Raphson solver overshoots past the lower bound of -1 on the first iteration when the solution is a strongly negative rate. The code then unconditionally returns `#NUM!`. **Fix:** Replace the unconditional error with a bisection-based clamp. When Newton-Raphson overshoots past -1, bisect between the current rate and -1: `newRate = (rate - 1) / 2`. This is guaranteed to stay in the valid domain (`> -1`) and converges linearly until close enough for quadratic Newton convergence to take over. ### How did you test your changes? Added 5 unit tests in the private tests repo covering: - Bug reproduction: `[-150000, 12000, 15000, 18000]` with default guess - Reversed cash flow signs: `[150000, -12000, -15000, -18000]` - Highly negative IRR (near total loss): `[-10000, 100, 100, 100]` - Negative IRR with explicit guess - Large investment with many small returns All 42 IRR tests pass (37 existing + 5 new), no regressions. ### Types of changes - [x] Bug fix (a non-breaking change that fixes an issue) ### Related issues: 1. Fixes https://github.com/handsontable/hyperformula/issues/1628 ### Checklist: - [x] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/guide/contributing.html) and I confirm that my code follows the code style of this project. - [ ] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [x] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. - [x] My change is compatible with Microsoft Excel. - [x] My change is compatible with Google Sheets. - [ ] I described my changes in the [CHANGELOG.md](https://github.com/handsontable/hyperformula/blob/master/CHANGELOG.md) file. - [ ] My changes require a documentation update. - [ ] My changes require a migration guide. --- > [!NOTE] > **Low Risk** > Small, localized numerical-solver change plus non-runtime test/benchmark script updates; primary risk is altered IRR convergence behavior on edge-case inputs. > > **Overview** > Fixes `IRR` returning `#NUM!` for strongly negative solutions by clamping Newton-Raphson iterations in `irrCore` when the next step overshoots past `-1` (bisects back into the valid domain instead of immediately erroring). > > Updates tooling/docs around the private test suite: renames the setup script to `test:setup-private`, adjusts `fetch-tests.sh` to create a missing branch from `develop` (and pull appropriately), and repoints benchmark scripts to `test/hyperformula-tests/performance`. Also records the IRR fix in `CHANGELOG.md`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 34b12650e93654b1187f623fe4972c418d693643. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- CHANGELOG.md | 4 ++++ package.json | 12 ++++++------ src/interpreter/plugin/FinancialPlugin.ts | 16 ++++++++++------ test/README.md | 4 ++-- test/fetch-tests.sh | 9 +++++---- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f3cd566..ce7ab58d2 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] +### Fixed + +- Fixed the IRR function returning `#NUM!` error when the initial investment significantly exceeds the sum of returns. [#1628](https://github.com/handsontable/hyperformula/issues/1628) + ## [3.2.0] - 2026-02-19 ### Added diff --git a/package.json b/package.json index ff4399c57..5be515bad 100644 --- a/package.json +++ b/package.json @@ -76,10 +76,10 @@ "verify:publish-package": "npm pack | node script/check-publish-package.js", "verify:typings": "tsc --noEmit", "test": "npm-run-all lint test:jest test:browser", - "test:fetch-private": "bash test/fetch-tests.sh", + "test:setup-private": "bash test/fetch-tests.sh", "test:jest": "cross-env NODE_ICU_DATA=node_modules/full-icu jest", "test:watch": "npm run test:jest -- --watch", - "test:tdd": "npm run test:jest -- --watch function-value", + "test:tmp": "npm run test:jest -- --watch function-irr", "test:coverage": "npm run test:jest -- --coverage", "test:logMemory": "npm run test:jest -- --runInBand --logHeapUsage", "test:performance": "npm run benchmark:basic && npm run benchmark:cruds", @@ -88,10 +88,10 @@ "test:browser": "cross-env-shell BABEL_ENV=dist env-cmd -f ht.config.js karma start", "test:browser.debug": "cross-env-shell BABEL_ENV=dist NODE_ENV=debug env-cmd -f ht.config.js karma start", "typedoc:build-api": "cross-env NODE_OPTIONS=--openssl-legacy-provider typedoc --options .typedoc.md.ts", - "benchmark:basic": "npm run tsnode test/performance/run-basic-benchmark.ts", - "benchmark:cruds": "npm run tsnode test/performance/run-cruds-benchmark.ts", - "benchmark:write-to-file": "npm run tsnode test/performance/write-to-file.ts", - "benchmark:compare-benchmarks": "npm run tsnode test/performance/compare-benchmarks.ts", + "benchmark:basic": "npm run tsnode test/hyperformula-tests/performance/run-basic-benchmark.ts", + "benchmark:cruds": "npm run tsnode test/hyperformula-tests/performance/run-cruds-benchmark.ts", + "benchmark:write-to-file": "npm run tsnode test/hyperformula-tests/performance/write-to-file.ts", + "benchmark:compare-benchmarks": "npm run tsnode test/hyperformula-tests/performance/compare-benchmarks.ts", "lint": "eslint . --ext .js,.ts", "lint:fix": "eslint . --ext .js,.ts --fix", "audit": "npm audit --omit=dev", diff --git a/src/interpreter/plugin/FinancialPlugin.ts b/src/interpreter/plugin/FinancialPlugin.ts index 46d7bac25..0071f44e7 100644 --- a/src/interpreter/plugin/FinancialPlugin.ts +++ b/src/interpreter/plugin/FinancialPlugin.ts @@ -876,7 +876,16 @@ function irrCore(values: number[], guess: number): number | CellError { } // Newton-Raphson step - const newRate = rate - npv / dnpv + let newRate = rate - npv / dnpv + + if (!isFinite(newRate)) { + return new CellError(ErrorType.NUM) + } + + // Clamp: when Newton overshoots past -1, bisect between current rate and -1 + if (newRate <= -1) { + newRate = (rate - 1) / 2 + } // Check for convergence based on rate change if (Math.abs(newRate - rate) < epsMax) { @@ -884,11 +893,6 @@ function irrCore(values: number[], guess: number): number | CellError { } rate = newRate - - // Check for invalid rate - if (!isFinite(rate) || rate <= -1) { - return new CellError(ErrorType.NUM) - } } return new CellError(ErrorType.NUM) diff --git a/test/README.md b/test/README.md index a1347926b..122e03ae1 100644 --- a/test/README.md +++ b/test/README.md @@ -12,7 +12,7 @@ The private test suite is kept in the `hyperformula-tests` repository. Once you Whenenever you switch branch in the main repository, you need to fetch the private test suite to the `test/hyperformula-tests` directory by running: ``` -npm run test:fetch-private +npm run test:setup-private ``` @@ -45,7 +45,7 @@ This file is located in the `test` directory. 3. **Checkout matching branch** – In `hyperformula-tests`: - Fetches from `origin` - Checks out the branch if it exists locally or on `origin` - - Otherwise checks out `develop` + - If the branch doesn't exist, creates it from `develop` 4. **Pull latest** – Runs `git pull origin` on the checked-out branch. diff --git a/test/fetch-tests.sh b/test/fetch-tests.sh index 932e7e18c..cbcc5671c 100755 --- a/test/fetch-tests.sh +++ b/test/fetch-tests.sh @@ -28,16 +28,17 @@ fi echo "Checking out branch $CURRENT_BRANCH in hyperformula-tests..." -# 3. Checkout matching branch in hyperformula-tests or fall back to develop +# 3. Checkout matching branch in hyperformula-tests or create it if it doesn't exist cd "$HYPERFORMULA_TESTS_DIR" 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 else + echo "Branch $CURRENT_BRANCH not found in hyperformula-tests, creating from develop..." git checkout develop + git pull origin develop + git checkout -b "$CURRENT_BRANCH" fi - -# 4. Pull changes from origin -git pull origin "$(git rev-parse --abbrev-ref HEAD)" From 266089d8dcabd398f9c868f6b072d3a77af31495 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 24 Mar 2026 10:03:50 +0100 Subject: [PATCH 05/20] Fix: ADDRESS function ignores defaultValue when user provides empty value for an optional parameter (#1631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When a user writes `=ADDRESS(1,1,)` or `=ADDRESS(1,1,1,)`, the empty argument is coerced to `0`/`false` instead of using the parameter's declared `defaultValue`. Excel 2021 and Google Sheets treat empty args as the zero-value for the type (`0`/`FALSE`) for **all functions except ADDRESS**, where empty `absNum` and `a1Style` use their declared defaults (1 and `true`). Fixes #1632 ## Fix - Add `emptyAsDefault` opt-in flag to `FunctionArgument` interface - In `coerceArgumentsToRequiredTypes`: when `rawArg === EmptyValue` AND `emptyAsDefault` is set AND `defaultValue` is declared → substitute `defaultValue` - Apply `emptyAsDefault: true` only to ADDRESS `absNum` and `a1Style` parameters ## Tests Regression tests in `handsontable/hyperformula-tests` (branch `fix/empty-default-value`): - ADDRESS: isolated tests for empty `absNum`, empty `a1Style`, both empty - LOG, MATCH, VLOOKUP, HLOOKUP: confirm empty → zero-value (not defaultValue) - `optional-parameters.spec.ts`: confirms empty args use zero-value coercion (not defaultValue) when `emptyAsDefault` is not set --- > [!NOTE] > **Medium Risk** > Touches core `FunctionPlugin` argument evaluation/coercion to distinguish syntactically empty arguments, which could subtly affect coercion behavior across many functions if misapplied. Change is gated behind an opt-in `emptyAsDefault` flag and only enabled for `ADDRESS` parameters in this PR. > > **Overview** > Fixes `ADDRESS` so syntactically empty optional arguments (e.g. `=ADDRESS(2,3,,FALSE())`) use the parameter `defaultValue` instead of being coerced to the type’s zero-value. > > Adds an opt-in `emptyAsDefault` flag to `FunctionArgument` and extends `FunctionPlugin`’s argument evaluation pipeline to track whether each argument was syntactically empty, allowing coercion to substitute `defaultValue` when `emptyAsDefault` is enabled. Documentation and changelog are updated to reflect the new option and the `ADDRESS` behavior fix. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7c6fc7c8097bd45af74f645ba4e6355427c201b7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + docs/guide/custom-functions.md | 1 + src/interpreter/plugin/AddressPlugin.ts | 4 +- src/interpreter/plugin/FunctionPlugin.ts | 54 ++++++++++++++++++------ 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7ab58d2..6e08b5fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - Fixed the IRR function returning `#NUM!` error when the initial investment significantly exceeds the sum of returns. [#1628](https://github.com/handsontable/hyperformula/issues/1628) +- Fixed the ADDRESS function ignoring `defaultValue` when arguments are syntactically empty (e.g., `=ADDRESS(2,3,,FALSE())`). [#1632](https://github.com/handsontable/hyperformula/issues/1632) ## [3.2.0] - 2026-02-19 diff --git a/docs/guide/custom-functions.md b/docs/guide/custom-functions.md index 10d1db153..68eedd9a4 100644 --- a/docs/guide/custom-functions.md +++ b/docs/guide/custom-functions.md @@ -423,6 +423,7 @@ You can set the following argument validation options: | `maxValue` | Number | If set: numerical arguments need to be less than or equal to `maxValue`. | | `lessThan` | Number | If set: numerical argument needs to be less than `lessThan`. | | `greaterThan` | Number | If set: numerical argument needs to be greater than `greaterThan`. | +| `emptyAsDefault` | Boolean | `true`: an empty argument (e.g., `=FUNC(1,,3)`) is treated as missing and falls back to `defaultValue`. By default (`false`), empty arguments are coerced to the zero-value for their type (`0`, `FALSE`, or `""`). Requires `defaultValue` to be set. | In your function plugin, in the static `implementedFunctions` property, add an array called `parameters`: diff --git a/src/interpreter/plugin/AddressPlugin.ts b/src/interpreter/plugin/AddressPlugin.ts index d60d132c1..7e37a42e4 100644 --- a/src/interpreter/plugin/AddressPlugin.ts +++ b/src/interpreter/plugin/AddressPlugin.ts @@ -26,8 +26,8 @@ export class AddressPlugin extends FunctionPlugin implements FunctionPluginTypec parameters: [ {argumentType: FunctionArgumentType.NUMBER}, {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 1, minValue: 1, maxValue: 4}, - {argumentType: FunctionArgumentType.BOOLEAN, optionalArg: true, defaultValue: true}, + {argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 1, minValue: 1, maxValue: 4, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.BOOLEAN, optionalArg: true, defaultValue: true, emptyAsDefault: true}, {argumentType: FunctionArgumentType.STRING, optionalArg: true}, ] }, diff --git a/src/interpreter/plugin/FunctionPlugin.ts b/src/interpreter/plugin/FunctionPlugin.ts index 9b3ffcd5d..810f14047 100644 --- a/src/interpreter/plugin/FunctionPlugin.ts +++ b/src/interpreter/plugin/FunctionPlugin.ts @@ -231,6 +231,22 @@ export interface FunctionArgument { * If set, numerical arguments need to be greater than `greaterThan`. */ 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`. + * + * By default (`false`), an empty argument is coerced to the zero-value for its + * type (`0` for numbers, `FALSE` for booleans, `""` for strings). + * + * | Formula | `emptyAsDefault: false` (default) | `emptyAsDefault: true` | + * |------------------|-----------------------------------|-------------------------------| + * | `ADDRESS(2,3)` | uses `defaultValue` for 3rd arg | uses `defaultValue` for 3rd arg | + * | `ADDRESS(2,3,)` | uses `0` for 3rd arg | uses `defaultValue` for 3rd arg | + * + * Requires `defaultValue` to be set. + */ + emptyAsDefault?: boolean, } export type PluginFunctionType = (ast: ProcedureAst, state: InterpreterState) => InterpreterValue @@ -280,16 +296,17 @@ export abstract class FunctionPlugin implements FunctionPluginTypecheck InterpreterValue, ): RawInterpreterValue => { - const evaluatedArguments: [InterpreterValue, boolean][] = this.evaluateArguments(args, state, metadata) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const argumentValues: InterpreterValue[] = evaluatedArguments.map(([value, _]: [InterpreterValue, boolean]) => value as InterpreterValue) - const argumentIgnorableFlags = evaluatedArguments.map(([_, ignorable]) => ignorable) + const evaluatedArguments = this.evaluateArguments(args, state, metadata) + const argumentValues: InterpreterValue[] = evaluatedArguments.map(([value]) => value) + const argumentIgnorableFlags = evaluatedArguments.map(([, ignorable]) => ignorable) + const syntacticallyEmptyFlags = evaluatedArguments.map(([, , empty]) => empty) const argumentMetadata = this.buildMetadataForEachArgumentValue(argumentValues.length, metadata) const isVectorizationOn = state.arraysFlag && !metadata.vectorizationForbidden @@ -404,13 +421,13 @@ export abstract class FunctionPlugin implements FunctionPluginTypecheck [ ...Array(resultArrayWidth).keys() ].map(col => { const vectorizedArguments = this.vectorizeAndBroadcastArgumentsIfNecessary(isVectorizationOn, argumentValues, argumentMetadata, row, col) - const result = this.calculateSingleCellOfResultArray(state, vectorizedArguments, argumentMetadata, argumentIgnorableFlags, functionImplementation, metadata.returnNumberType) + const result = this.calculateSingleCellOfResultArray(state, vectorizedArguments, argumentMetadata, argumentIgnorableFlags, syntacticallyEmptyFlags, functionImplementation, metadata.returnNumberType) if (result instanceof SimpleRangeValue) { throw new Error('Function returning array cannot be vectorized.') @@ -428,10 +445,11 @@ export abstract class FunctionPlugin implements FunctionPluginTypecheck[], argumentsMetadata: FunctionArgument[], argumentIgnorableFlags: boolean[], + syntacticallyEmptyFlags: boolean[], functionImplementation: (...arg: any) => InterpreterValue, returnNumberType: NumberType | undefined, ): RawInterpreterValue { - const coercedArguments = this.coerceArgumentsToRequiredTypes(state, vectorizedArguments, argumentsMetadata, argumentIgnorableFlags) + const coercedArguments = this.coerceArgumentsToRequiredTypes(state, vectorizedArguments, argumentsMetadata, argumentIgnorableFlags, syntacticallyEmptyFlags) if (coercedArguments instanceof CellError) { return coercedArguments @@ -446,12 +464,18 @@ export abstract class FunctionPlugin implements FunctionPluginTypecheck[], argumentsMetadata: FunctionArgument[], argumentIgnorableFlags: boolean[], + syntacticallyEmptyFlags: boolean[], ): CellError | Maybe[] { const coercedArguments: Maybe[] = [] for (let i = 0; i < argumentsMetadata.length; i++) { const argumentMetadata = argumentsMetadata[i] - const argumentValue = vectorizedArguments[i] !== undefined ? vectorizedArguments[i] : argumentMetadata?.defaultValue + const rawArg = vectorizedArguments[i] + const argumentValue = rawArg === undefined + ? argumentMetadata?.defaultValue + : (syntacticallyEmptyFlags[i] && argumentMetadata?.emptyAsDefault && argumentMetadata?.defaultValue !== undefined) + ? argumentMetadata.defaultValue + : rawArg if (argumentValue === undefined) { coercedArguments.push(undefined) @@ -489,8 +513,10 @@ export abstract class FunctionPlugin implements FunctionPluginTypecheck [this.evaluateAst(ast, state), false]) + protected evaluateArguments(args: Ast[], state: InterpreterState, metadata: FunctionMetadata): [InterpreterValue, boolean, boolean][] { + return metadata.expandRanges + ? this.listOfScalarValues(args, state) + : args.map((ast) => [this.evaluateAst(ast, state), false, ast.type === AstNodeType.EMPTY]) } protected buildMetadataForEachArgumentValue(numberOfArgumentValuesPassed: number, metadata: FunctionMetadata): FunctionArgument[] { From 72db14bb35dbfbbe4b8b7f1af9e47325e6d1048b Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Thu, 26 Mar 2026 11:16:15 +0100 Subject: [PATCH 06/20] Describe Definition of Done for the code changes (#1642) Adding `Definition of Done for the code changes` to the DEV_DOCS.md --- > [!NOTE] > **Low Risk** > Documentation-only change that adds contribution/process guidance; no runtime behavior or data/security impact. > > **Overview** > Adds a new **"Definition of Done"** section to `DEV_DOCS.md` describing what production-code PRs must include before review (code changes incl. i18n packs when relevant, tests expectations for internal vs. external contributors, related docs/migration guide updates, JSDoc/technical docs, changelog entry, and PR description). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0d7351fc67451c647f9db8d79d8079fcbd816728. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- DEV_DOCS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/DEV_DOCS.md b/DEV_DOCS.md index 0197a804c..3349b9e00 100644 --- a/DEV_DOCS.md +++ b/DEV_DOCS.md @@ -2,6 +2,23 @@ Random notes and things to know useful for maintainers and contributors. +## Definition of Done for the code changes + +Each change to the production code (bugfixes, new features, improvements) must include these elements. They must be present in the pull request BEFORE requesting the code review. + +- changes to the production code + - including changes to all supported language packs in `src/i18n/languages` directory (if applicable) +- automatic tests + - for bugfixes: at least one test reproducing the bug + - for new features: a set of tests describing the feature specification precisely + - pull requests from external contributors should include tests in `tests/` directory (they will be moved to the private repository by the internal team) + - internal team adds tests directly to the private repository (through a separate pull request) +- updates to documentation related to the change + - for breaking changes: a section in the migration guide +- technical documentation in the form of the jsdoc comments (high-level description of the concepts used in the more complex code fragments) +- changelog entry +- pull request description + ## Sources of the function translations HF supports internationalization and provides the localized function names for all built-in languages. When looking for the valid translations for the new functions, try these sources: From 0c1d1304fa1a3c7d60004f0d2c90233c779cd83f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 26 Mar 2026 15:13:43 +0100 Subject: [PATCH 07/20] Add TEXTJOIN function with i18n and documentation (#1640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the `TEXTJOIN` function — joins text from multiple strings and/or ranges with a configurable delimiter. Replaces #1625 (was opened from fork, now from upstream branch directly). ### Features - Scalar and array/range delimiter support with cycling behavior - `ignore_empty` parameter to skip empty strings - Type coercion (numbers, booleans → strings) - Error propagation from both delimiter and text arguments - 32,767 character limit (Excel compatibility) - i18n translations for all 17 supported languages - Documentation in `built-in-functions.md` ### Implementation - New `textjoin` method + `flattenArgToStrings` helper in `TextPlugin` - `repeatLastArgs: 1` metadata pattern (same as SUMPRODUCT, etc.) - Defensive `CellError` check on `coerceScalarToString` return value ### Changed files | File | Change | |------|--------| | `src/interpreter/plugin/TextPlugin.ts` | `textjoin()` + `flattenArgToStrings()` | | `src/error-message.ts` | `TextJoinResultTooLong` message | | `src/i18n/languages/*.ts` (17 files) | TEXTJOIN translations | | `docs/guide/built-in-functions.md` | TEXTJOIN row (alphabetically between TEXT and TRIM) | ### Review feedback addressed (from #1625) - Tests moved to private `hyperformula-tests` repo (companion PR pending) - Fixed docs alphabetical ordering - Fixed unsafe `as string` cast in `flattenArgToStrings` ## Test plan - [x] 35 tests in `hyperformula-tests/unit/interpreter/function-textjoin.spec.ts` - [x] Full suite: 480 suites / 5396 tests passed --- > [!NOTE] > **Medium Risk** > Adds a new interpreter function (`TEXTJOIN`) with range flattening and type coercion, which touches formula evaluation paths and may introduce edge-case regressions around error propagation and large-string handling. > > **Overview** > Adds the new `TEXTJOIN` spreadsheet function, including interpreter support for joining scalars and ranges with a delimiter (including delimiter cycling), optional skipping of empty strings, and consistent error propagation. > > Introduces a new `ErrorMessage.ResultTooLong` and enforces Excel’s 32,767-character output limit (returning `#VALUE!` when exceeded). > > Updates function documentation, the unreleased changelog, and adds `TEXTJOIN` translations across all supported language packs. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 81426d7da682abb5d5cbf9f1c989125777d07abb. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Kuba Sekowski Co-authored-by: Claude --- CHANGELOG.md | 4 + docs/guide/built-in-functions.md | 1 + src/error-message.ts | 1 + src/i18n/languages/csCZ.ts | 1 + src/i18n/languages/daDK.ts | 1 + src/i18n/languages/deDE.ts | 1 + src/i18n/languages/enGB.ts | 1 + src/i18n/languages/esES.ts | 1 + src/i18n/languages/fiFI.ts | 1 + src/i18n/languages/frFR.ts | 1 + src/i18n/languages/huHU.ts | 1 + src/i18n/languages/itIT.ts | 1 + src/i18n/languages/nbNO.ts | 1 + src/i18n/languages/nlNL.ts | 1 + src/i18n/languages/plPL.ts | 1 + src/i18n/languages/ptPT.ts | 1 + src/i18n/languages/ruRU.ts | 1 + src/i18n/languages/svSE.ts | 1 + src/i18n/languages/trTR.ts | 1 + src/interpreter/plugin/TextPlugin.ts | 157 ++++++++++++++++++++------- 20 files changed, 142 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e08b5fcb..f16a7d215 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 a new function: TEXTJOIN. [#1640](https://github.com/handsontable/hyperformula/pull/1640) + ### Fixed - Fixed the IRR function returning `#NUM!` error when the initial investment significantly exceeds the sum of returns. [#1628](https://github.com/handsontable/hyperformula/issues/1628) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index dd9777523..eb8428e18 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -502,6 +502,7 @@ Total number of functions: **{{ $page.functionsCount }}** | SUBSTITUTE | Returns string where occurrences of Old_text are replaced by New_text. Replaces only specific occurrence if last parameter is provided. | SUBSTITUTE(Text, Old_text, New_text, [Occurrence]) | | T | Returns text if given value is text, empty string otherwise. | T(Value) | | TEXT | Converts a number into text according to a given format.
By default, accepts the same formats that can be passed to the [`dateFormats`](../api/interfaces/configparams.md#dateformats) option, but can be further customized with the [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) option. | TEXT(Number, Format) | +| TEXTJOIN | Joins text from multiple strings and/or ranges with a delimiter. Supports array/range delimiters that cycle through gaps. When ignore_empty is TRUE, empty strings are skipped. Returns #VALUE! if result exceeds 32,767 characters. | TEXTJOIN(Delimiter, Ignore_empty, Text1, [Text2, ...]) | | TRIM | Strips extra spaces from text. | TRIM("Text") | | UNICHAR | Returns the character created by using provided code point. | UNICHAR(Number) | | UNICODE | Returns the Unicode code point of a first character of a text. | UNICODE(Text) | diff --git a/src/error-message.ts b/src/error-message.ts index d9fded74c..5e3afdbea 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -73,6 +73,7 @@ export class ErrorMessage { public static ComplexNumberExpected = 'Complex number expected.' public static ShouldBeIorJ = 'Should be \'i\' or \'j\'.' public static SizeMismatch = 'Array dimensions mismatched.' + public static ResultTooLong = 'Result exceeds the maximum allowed length.' public static FunctionName = (arg: string) => `Function name ${arg} not recognized.` public static NamedExpressionName = (arg: string) => `Named expression ${arg} not recognized.` public static LicenseKey = (arg: string) => `License key is ${arg}.` diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index d0ed619fb..d3f0deaf9 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'TBILLPRICE', TBILLYIELD: 'TBILLYIELD', TEXT: 'HODNOTA.NA.TEXT', + TEXTJOIN: 'TEXTJOIN', TIME: 'ČAS', TIMEVALUE: 'ČASHODN', TODAY: 'DNES', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index d8838ff19..12607c126 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'STATSOBLIGATION.KURS', TBILLYIELD: 'STATSOBLIGATION.AFKAST', TEXT: 'TEKST', + TEXTJOIN: 'TEKST.KOMBINER', TIME: 'TID', TIMEVALUE: 'TIDSVÆRDI', TODAY: 'IDAG', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index d05f7dcdd..025fd81d1 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'TBILLKURS', TBILLYIELD: 'TBILLRENDITE', TEXT: 'TEXT', + TEXTJOIN: 'TEXTVERKETTEN', TIME: 'ZEIT', TIMEVALUE: 'ZEITWERT', TODAY: 'HEUTE', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 233a354da..aa70001a3 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -217,6 +217,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'TBILLPRICE', TBILLYIELD: 'TBILLYIELD', TEXT: 'TEXT', + TEXTJOIN: 'TEXTJOIN', TIME: 'TIME', TIMEVALUE: 'TIMEVALUE', TODAY: 'TODAY', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 7593a79ed..a15326f25 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -215,6 +215,7 @@ export const dictionary: RawTranslationPackage = { TBILLPRICE: 'LETRA.DE.TES.PRECIO', TBILLYIELD: 'LETRA.DE.TES.RENDTO', TEXT: 'TEXTO', + TEXTJOIN: 'UNIRCADENAS', TIME: 'NSHORA', TIMEVALUE: 'HORANUMERO', TODAY: 'HOY', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 25d54032a..9deeed016 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'OBLIG.HINTA', TBILLYIELD: 'OBLIG.TUOTTO', TEXT: 'TEKSTI', + TEXTJOIN: 'TEKSTI.YHDISTÄ', TIME: 'AIKA', TIMEVALUE: 'AIKA_ARVO', TODAY: 'TÄMÄ.PÄIVÄ', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 7733d20b4..dd467e7e4 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'PRIX.BON.TRESOR', TBILLYIELD: 'RENDEMENT.BON.TRESOR', TEXT: 'TEXTE', + TEXTJOIN: 'JOINDRE.TEXTE', TIME: 'TEMPS', TIMEVALUE: 'TEMPSVAL', TODAY: 'AUJOURDHUI', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index d1341fb21..915420c49 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'KJEGY.ÁR', TBILLYIELD: 'KJEGY.HOZAM', TEXT: 'SZÖVEG', + TEXTJOIN: 'SZÖVEGÖSSZEFŰZÉS', TIME: 'IDŐ', TIMEVALUE: 'IDŐÉRTÉK', TODAY: 'MA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 28f4ece50..000f45a1f 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'BOT.PREZZO', TBILLYIELD: 'BOT.REND', TEXT: 'TESTO', + TEXTJOIN: 'UNISCI.TESTO', TIME: 'ORARIO', TIMEVALUE: 'ORARIO.VALORE', TODAY: 'OGGI', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 61ec76ccf..d521aead4 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'TBILLPRIS', TBILLYIELD: 'TBILLAVKASTNING', TEXT: 'TEKST', + TEXTJOIN: 'TEKST.KOMBINER', TIME: 'TID', TIMEVALUE: 'TIDSVERDI', TODAY: 'IDAG', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 4de49b7a4..1536ea5a5 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'SCHATK.PRIJS', TBILLYIELD: 'SCHATK.REND', TEXT: 'TEKST', + TEXTJOIN: 'TEKST.KOPPELEN', TIME: 'TIJD', TIMEVALUE: 'TIJDWAARDE', TODAY: 'VANDAAG', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 7c0a08756..d5651c77d 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'CENA.BS', TBILLYIELD: 'RENT.BS', TEXT: 'TEKST', + TEXTJOIN: 'POŁĄCZ.TEKSTY', TIME: 'CZAS', TIMEVALUE: 'CZAS.WARTOŚĆ', TODAY: 'DZIŚ', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index cd3fc715d..ee5d9597e 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'OTNVALOR', TBILLYIELD: 'OTNLUCRO', TEXT: 'TEXTO', + TEXTJOIN: 'UNIRTEXTO', TIME: 'TEMPO', TIMEVALUE: 'VALOR.TEMPO', TODAY: 'HOJE', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 66032d3cd..d11284169 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'ЦЕНАКЧЕК', TBILLYIELD: 'ДОХОДКЧЕК', TEXT: 'ТЕКСТ', + TEXTJOIN: 'ОБЪЕДИНИТЬ', TIME: 'ВРЕМЯ', TIMEVALUE: 'ВРЕМЗНАЧ', TODAY: 'СЕГОДНЯ', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index d4741d6fc..4bc4f46c7 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'SSVXPRIS', TBILLYIELD: 'SSVXRÄNTA', TEXT: 'TEXT', + TEXTJOIN: 'TEXTJOIN', TIME: 'KLOCKSLAG', TIMEVALUE: 'TIDVÄRDE', TODAY: 'IDAG', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 38507c24b..d23e8f2f3 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -215,6 +215,7 @@ const dictionary: RawTranslationPackage = { TBILLPRICE: 'HTAHDEĞER', TBILLYIELD: 'HTAHÖDEME', TEXT: 'METNEÇEVİR', + TEXTJOIN: 'METİNBİRLEŞTİR', TIME: 'ZAMAN', TIMEVALUE: 'ZAMANSAYISI', TODAY: 'BUGÜN', diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index 4bb5832df..4326a57d6 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -7,6 +7,7 @@ import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' import {Maybe} from '../../Maybe' import {ProcedureAst} from '../../parser' +import {coerceScalarToString} from '../ArithmeticHelper' import {InterpreterState} from '../InterpreterState' import {SimpleRangeValue} from '../../SimpleRangeValue' import {ExtendedNumber, InterpreterValue, isExtendedNumber, RawScalarValue, InternalScalarValue} from '../InterpreterValue' @@ -20,7 +21,7 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec 'CONCATENATE': { method: 'concatenate', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ], repeatLastArgs: 1, expandRanges: true, @@ -28,134 +29,143 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec 'EXACT': { method: 'exact', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING } ] }, 'SPLIT': { method: 'split', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER }, ] }, 'LEN': { method: 'len', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'LOWER': { method: 'lower', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'MID': { method: 'mid', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.NUMBER}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER }, + { argumentType: FunctionArgumentType.NUMBER }, ] }, 'TRIM': { method: 'trim', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'T': { method: 't', parameters: [ - {argumentType: FunctionArgumentType.SCALAR} + { argumentType: FunctionArgumentType.SCALAR } ] }, 'N': { method: 'n', parameters: [ - {argumentType: FunctionArgumentType.ANY} + { argumentType: FunctionArgumentType.ANY } ] }, 'PROPER': { method: 'proper', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'CLEAN': { method: 'clean', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'REPT': { method: 'rept', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER }, ] }, 'RIGHT': { method: 'right', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1 }, ] }, 'LEFT': { method: 'left', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1 }, ] }, 'REPLACE': { method: 'replace', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER }, + { argumentType: FunctionArgumentType.NUMBER }, + { argumentType: FunctionArgumentType.STRING } ] }, 'SEARCH': { method: 'search', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1 }, ] }, 'SUBSTITUTE': { method: 'substitute', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, optionalArg: true} + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, optionalArg: true } ] }, 'FIND': { method: 'find', parameters: [ - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.STRING}, - {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1}, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.STRING }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1 }, ] }, 'UPPER': { method: 'upper', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ] }, 'VALUE': { method: 'value', parameters: [ - {argumentType: FunctionArgumentType.SCALAR} + { argumentType: FunctionArgumentType.SCALAR } ] }, + 'TEXTJOIN': { + method: 'textjoin', + repeatLastArgs: 1, + parameters: [ + {argumentType: FunctionArgumentType.ANY}, + {argumentType: FunctionArgumentType.BOOLEAN}, + {argumentType: FunctionArgumentType.ANY}, + ], + }, } /** @@ -424,6 +434,78 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec }) } + /** + * Corresponds to TEXTJOIN(delimiter, ignore_empty, text1, [text2], …) + * + * Joins text from multiple strings/ranges with a configurable delimiter. + * Supports array/range delimiters that cycle through gaps between text values. + * When ignore_empty is TRUE, empty strings are skipped. + * Returns #VALUE! if the result exceeds 32,767 characters (Excel cell content limit). + * + * @param {ProcedureAst} ast - The procedure AST node + * @param {InterpreterState} state - The interpreter state + */ + public textjoin(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('TEXTJOIN'), + (delimiterArg: InternalScalarValue | SimpleRangeValue, + ignoreEmpty: boolean, + ...textArgs: (InternalScalarValue | SimpleRangeValue)[]) => { + + const delimiters = this.flattenArgToStrings(delimiterArg) + if (delimiters instanceof CellError) { + return delimiters + } + + const texts: string[] = [] + for (const arg of textArgs) { + const coerced = this.flattenArgToStrings(arg) + if (coerced instanceof CellError) { + return coerced + } + texts.push(...coerced) + } + + const parts = ignoreEmpty ? texts.filter((t) => t !== '') : texts + + if (parts.length === 0) { + return '' + } + + const result = parts.reduce((acc, part, i) => + i === 0 ? part : acc + delimiters[(i - 1) % delimiters.length] + part + , '') + + if (result.length > 32767) { + return new CellError(ErrorType.VALUE, ErrorMessage.ResultTooLong) + } + return result + } + ) + } + + /** + * Flattens a scalar or range argument into an array of coerced strings. + * Returns a CellError immediately if any value in the argument is an error or cannot be coerced. + * + * @param {InternalScalarValue | SimpleRangeValue} arg - Scalar or range to flatten + * @returns {string[] | CellError} - Array of string values, or the first error encountered + */ + private flattenArgToStrings(arg: InternalScalarValue | SimpleRangeValue): string[] | CellError { + const values = arg instanceof SimpleRangeValue ? arg.valuesFromTopLeftCorner() : [arg] + const result: string[] = [] + for (const val of values) { + if (val instanceof CellError) { + return val + } + const coerced = coerceScalarToString(val as InternalScalarValue) + if (coerced instanceof CellError) { + return coerced + } + result.push(coerced) + } + return result + } + /** * Parses a string to a numeric value, handling whitespace trimming and empty string validation. * @@ -443,4 +525,5 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec private escapeRegExpSpecialCharacters(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } + } From 04180f88af96274e763d569319d87613a76542f5 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 2 Apr 2026 15:42:46 +0200 Subject: [PATCH 08/20] Fix #1629: resolve memory leaks in LazilyTransformingAstService and UndoRedo (#1638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #1629. Closes #1633. Closes #1634. Two unbounded memory leaks in long-running HyperFormula instances: - **`LTAS.transformations[]`** grew linearly with every structural operation (addRows, removeRows, moveCells, etc.) and was never cleaned up. Fixed by introducing **threshold-based compaction** with `versionOffset` — once 50+ transformations accumulate, all consumers (FormulaVertex, ColumnIndex) are force-updated, then the array is released while the logical version remains monotonically increasing. - **`UndoRedo.oldData`** grew linearly even when entries were evicted from the undo stack. Fixed by tracking which LTAS versions each `UndoEntry` references (`getReferencedOldDataVersions()`), cleaning up on eviction/clear, guarding against writes when `undoLimit === 0`, and running orphan cleanup after compaction to handle a race condition where lazy-apply re-inserts already-evicted keys. ### Changed files | File | Change | |------|--------| | `LazilyTransformingAstService.ts` | `versionOffset`, `compact()`, `needsCompaction()` with threshold=50, offset-aware iteration | | `UndoRedo.ts` | `getReferencedOldDataVersions()` on interface + 7 subclasses, eviction cleanup, `undoLimit===0` guard, `cleanupOrphanedOldData()`, `forceApply` parity in `undoMoveRows`/`undoMoveColumns` | | `HyperFormula.ts` | Compaction trigger in `recomputeIfDependencyGraphNeedsIt()` | | `ColumnIndex.ts` | `forceApplyPostponedTransformations()` — iterates all ValueIndex entries | | `ColumnBinarySearch.ts` | No-op `forceApplyPostponedTransformations()` | | `SearchStrategy.ts` | New method on `ColumnSearchStrategy` interface | | `Operations.ts` | Added `columnSearch.forceApply` to undo path (no compact — centralized in HyperFormula.ts) | ### What is NOT fixed here - **Parser cache** (`ParserWithCaching`) — unbounded growth tracked separately in #1635 ## Test plan - 18 dedicated tests in `hyperformula-tests` — see companion PR in that repo - Full test suite: **480 suites / 5396 tests passed** - Benchmark validated threshold=50 as optimal (eager compaction is ~18× slower on a 2.5k formula sheet) --- > [!NOTE] > **Medium Risk** > Touches core recalculation/transform and undo/redo paths; while aimed at memory safety, compaction/cleanup timing could affect formula correctness or undo behavior in edge cases. > > **Overview** > Prevents unbounded memory growth in long-running engines by **adding threshold-based compaction** of lazy formula transformations and by **cleaning up undo snapshot (`oldData`) entries** when undo/redo stack entries are cleared or evicted. > > Introduces new config `maxPendingLazyTransformations` (default `50`) and wires it into engine construction; when the threshold is reached, `HyperFormula` forces pending transformations to be applied (dependency graph + column search), compacts the transformation history, and prunes orphaned `UndoRedo.oldData`. Column search strategies now expose `forceApplyPostponedTransformations()` (real implementation for `ColumnIndex`, no-op for binary search), and undo for move operations ensures postponed transformations are applied before restoring old data. Documentation and changelog are updated accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d8ebe1d1f59ca7e9de9493154b6d36c211a4b3d2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Kuba Sekowski --- CHANGELOG.md | 3 + docs/guide/performance.md | 23 +++++ src/BuildEngineFactory.ts | 2 +- src/Config.ts | 6 ++ src/ConfigParams.ts | 15 +++ src/HyperFormula.ts | 16 ++++ src/LazilyTransformingAstService.ts | 70 ++++++++++++-- src/Lookup/ColumnBinarySearch.ts | 10 ++ src/Lookup/ColumnIndex.ts | 18 ++++ src/Lookup/SearchStrategy.ts | 7 ++ src/Operations.ts | 6 ++ src/UndoRedo.ts | 136 +++++++++++++++++++++++++++- 12 files changed, 304 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f16a7d215..9e3868bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- 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) ### Fixed +- Fixed a memory leak in `LazilyTransformingAstService` where the transformations array grew unboundedly, causing increasing memory usage over time. [#1629](https://github.com/handsontable/hyperformula/issues/1629) +- Fixed a memory leak in `UndoRedo` where `oldData` entries for evicted undo stack entries were never cleaned up, causing increasing memory usage over time. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Fixed the IRR function returning `#NUM!` error when the initial investment significantly exceeds the sum of returns. [#1628](https://github.com/handsontable/hyperformula/issues/1628) - Fixed the ADDRESS function ignoring `defaultValue` when arguments are syntactically empty (e.g., `=ADDRESS(2,3,,FALSE())`). [#1632](https://github.com/handsontable/hyperformula/issues/1632) diff --git a/docs/guide/performance.md b/docs/guide/performance.md index cc7f5e809..d8f900269 100644 --- a/docs/guide/performance.md +++ b/docs/guide/performance.md @@ -37,6 +37,29 @@ cells filled, but located very far from each other. the fill ratio of the sheet. Let the engine choose the best strategy for you. +## Lazy transformation cleanup + +Structural operations (adding/removing rows/columns, moving cells) create +transformations that are applied lazily to formulas. Over time, these +transformations accumulate in memory. HyperFormula automatically flushes +them when their count reaches the `maxPendingLazyTransformations` threshold +(default: 50). + +You can tune this setting to balance memory usage and CPU overhead: + +* **Lower values** (e.g., 10) reduce peak memory usage but trigger + cleanup more frequently, adding slight CPU overhead per flush. +* **Higher values** (e.g., 200) reduce the frequency of cleanup but + allow more memory to accumulate between flushes. +* The default of **50** works well for most use cases. + +```javascript +const hf = HyperFormula.buildEmpty({ + licenseKey: 'gpl-v3', + maxPendingLazyTransformations: 100, +}) +``` + ## Suspending automatic recalculations By default, HyperFormula recalculates formulas after every change. diff --git a/src/BuildEngineFactory.ts b/src/BuildEngineFactory.ts index 1faff1e88..62202a78c 100644 --- a/src/BuildEngineFactory.ts +++ b/src/BuildEngineFactory.ts @@ -72,7 +72,7 @@ export class BuildEngineFactory { const namedExpressions = new NamedExpressions() const functionRegistry = new FunctionRegistry(config) - const lazilyTransformingAstService = new LazilyTransformingAstService(stats) + const lazilyTransformingAstService = new LazilyTransformingAstService(stats, config.maxPendingLazyTransformations) const dependencyGraph = DependencyGraph.buildEmpty(lazilyTransformingAstService, config, functionRegistry, namedExpressions, stats) const columnSearch = buildColumnSearchStrategy(dependencyGraph, config, stats) const sheetMapping = dependencyGraph.sheetMapping diff --git a/src/Config.ts b/src/Config.ts index c345c97e9..d47323384 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -62,6 +62,7 @@ export class Config implements ConfigParams, ParserConfig { timeFormats: ['hh:mm', 'hh:mm:ss.sss'], thousandSeparator: '', undoLimit: 20, + maxPendingLazyTransformations: 50, useRegularExpressions: false, useWildcards: true, useColumnIndex: false, @@ -135,6 +136,8 @@ export class Config implements ConfigParams, ParserConfig { /** @inheritDoc */ public readonly undoLimit: number /** @inheritDoc */ + public readonly maxPendingLazyTransformations: number + /** @inheritDoc */ public readonly context: unknown /** @@ -198,6 +201,7 @@ export class Config implements ConfigParams, ParserConfig { useArrayArithmetic, useStats, undoLimit, + maxPendingLazyTransformations, useColumnIndex, useRegularExpressions, useWildcards, @@ -244,10 +248,12 @@ export class Config implements ConfigParams, ParserConfig { this.nullDate = configValueFromParamCheck(nullDate, instanceOfSimpleDate, 'IDate', 'nullDate') this.leapYear1900 = configValueFromParam(leapYear1900, 'boolean', 'leapYear1900') this.undoLimit = configValueFromParam(undoLimit, 'number', 'undoLimit') + this.maxPendingLazyTransformations = configValueFromParam(maxPendingLazyTransformations, 'number', 'maxPendingLazyTransformations') this.useRegularExpressions = configValueFromParam(useRegularExpressions, 'boolean', 'useRegularExpressions') this.useWildcards = configValueFromParam(useWildcards, 'boolean', 'useWildcards') this.matchWholeCell = configValueFromParam(matchWholeCell, 'boolean', 'matchWholeCell') validateNumberToBeAtLeast(this.undoLimit, 'undoLimit', 0) + validateNumberToBeAtLeast(this.maxPendingLazyTransformations, 'maxPendingLazyTransformations', 1) this.maxRows = configValueFromParam(maxRows, 'number', 'maxRows') validateNumberToBeAtLeast(this.maxRows, 'maxRows', 1) this.maxColumns = configValueFromParam(maxColumns, 'number', 'maxColumns') diff --git a/src/ConfigParams.ts b/src/ConfigParams.ts index e036731c4..ad7344a3b 100644 --- a/src/ConfigParams.ts +++ b/src/ConfigParams.ts @@ -402,6 +402,21 @@ export interface ConfigParams { * @category Undo and Redo */ undoLimit: number, + /** + * Controls memory usage for long-running instances by limiting the number of + * pending lazy transformations before cleanup occurs. + * + * Structural operations (adding/removing rows/columns, moving cells) create + * transformations that are applied lazily to formulas. This setting determines + * how many can accumulate before they are flushed and memory is reclaimed. + * + * Lower values reduce peak memory usage but may slightly increase CPU overhead. + * Higher values reduce overhead but allow more memory accumulation. + * + * @default 50 + * @category Engine + */ + maxPendingLazyTransformations: number, /** * When set to `true`, criteria in functions (SUMIF, COUNTIF, ...) are allowed to use regular expressions. * @default false diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index e7c4b5150..566957270 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -4595,6 +4595,20 @@ export class HyperFormula implements TypedEmitter { this._functionRegistry = newEngine.functionRegistry } + /** + * When enough transformations have accumulated, forces all formula vertices and + * column index entries to apply pending lazy transformations, then compacts the + * transformation history and cleans up orphaned undo oldData entries. + */ + private compactLazyTransformationsIfNeeded(): void { + if (this._lazilyTransformingAstService.needsCompaction()) { + this._dependencyGraph.forceApplyPostponedTransformations() + this._columnSearch.forceApplyPostponedTransformations() + this._lazilyTransformingAstService.compact() + this._lazilyTransformingAstService.undoRedo?.cleanupOrphanedOldData() + } + } + /** * Runs a recomputation starting from recently changed vertices. * @@ -4610,6 +4624,8 @@ export class HyperFormula implements TypedEmitter { const verticesToRecomputeFrom = this.dependencyGraph.verticesToRecompute() this.dependencyGraph.clearDirtyVertices() + this.compactLazyTransformationsIfNeeded() + if (verticesToRecomputeFrom.length > 0) { changes.addAll(this.evaluator.partialRun(verticesToRecomputeFrom)) } diff --git a/src/LazilyTransformingAstService.ts b/src/LazilyTransformingAstService.ts index 899f89d2a..204905d5c 100644 --- a/src/LazilyTransformingAstService.ts +++ b/src/LazilyTransformingAstService.ts @@ -11,21 +11,56 @@ import {StatType} from './statistics' import {Statistics} from './statistics/Statistics' import {UndoRedo} from './UndoRedo' +/** + * Manages lazy application of formula AST transformations. + * + * ## Problem + * Structural operations (adding/removing rows/columns, moving cells, renaming sheets) + * require updating every formula that references the affected area. Applying these + * transformations eagerly to all formulas after every operation is expensive, especially + * for large spreadsheets with many formulas. + * + * ## Solution: Lazy Transformation + * Instead of transforming all formulas immediately, this service stores transformations + * in a queue. Each formula vertex (FormulaVertex) and column index entry (ValueIndex) + * tracks its own version number. When a consumer needs up-to-date data, it calls + * `applyTransformations()` with its current version and receives all transformations + * accumulated since that version. + * + * ## Compaction + * Over time, the transformations array grows unboundedly. To prevent this memory leak, + * the engine periodically triggers compaction when the number of accumulated + * transformations reaches the configurable `maxPendingLazyTransformations`: + * + * 1. All FormulaVertex instances are forced to apply pending transformations + * (via `DependencyGraph.forceApplyPostponedTransformations()`). + * 2. All ColumnIndex entries are forced to apply pending transformations + * (via `ColumnSearchStrategy.forceApplyPostponedTransformations()`). + * 3. `compact()` is called, which advances `versionOffset` and clears the + * transformations array. + * 4. `UndoRedo.cleanupOrphanedOldData()` removes any oldData entries that were + * written during forced application but belong to already-evicted undo entries. + * + * The `versionOffset` ensures that version numbers remain globally consistent + * after compaction: `version() = versionOffset + transformations.length`. + */ export class LazilyTransformingAstService { public parser?: ParserWithCaching public undoRedo?: UndoRedo private transformations: FormulaTransformer[] = [] + private versionOffset: number = 0 private combinedTransformer?: CombinedTransformer constructor( private readonly stats: Statistics, + private readonly maxPendingLazyTransformations: number, ) { } public version(): number { - return this.transformations.length + return this.versionOffset + this.transformations.length } public addTransformation(transformation: FormulaTransformer): number { @@ -53,8 +88,9 @@ export class LazilyTransformingAstService { public applyTransformations(ast: Ast, address: SimpleCellAddress, version: number): [Ast, SimpleCellAddress, number] { this.stats.start(StatType.TRANSFORM_ASTS_POSTPONED) - for (let v = version; v < this.transformations.length; v++) { - const transformation = this.transformations[v] + const currentVersion = this.version() + for (let v = Math.max(version, this.versionOffset); v < currentVersion; v++) { + const transformation = this.transformations[v - this.versionOffset] if (transformation.isIrreversible()) { this.undoRedo!.storeDataForVersion(v, address, this.parser!.computeHashFromAst(ast)) this.parser!.rememberNewAst(ast) @@ -67,15 +103,37 @@ export class LazilyTransformingAstService { const cachedAst = this.parser!.rememberNewAst(ast) this.stats.end(StatType.TRANSFORM_ASTS_POSTPONED) - return [cachedAst, address, this.transformations.length] + return [cachedAst, address, currentVersion] } public* getTransformationsFrom(version: number, filter?: (transformation: FormulaTransformer) => boolean): IterableIterator { - for (let v = version; v < this.transformations.length; v++) { - const transformation = this.transformations[v] + const currentVersion = this.version() + for (let v = Math.max(version, this.versionOffset); v < currentVersion; v++) { + const transformation = this.transformations[v - this.versionOffset] if (!filter || filter(transformation)) { yield transformation } } } + + /** + * Returns true when enough transformations have accumulated to justify the cost + * of forcing all consumers (FormulaVertex, ColumnIndex) to apply pending changes. + */ + public needsCompaction(): boolean { + return this.transformations.length >= this.maxPendingLazyTransformations + } + + /** + * Compacts the transformations array by discarding all entries that have already + * been applied by every consumer. Safe to call only after all FormulaVertex and + * ColumnIndex consumers have been brought up to the current version. + * After calling, UndoRedo.cleanupOrphanedOldData() must be invoked to remove + * oldData entries written during forceApplyPostponedTransformations for + * already-evicted undo entries. + */ + public compact(): void { + this.versionOffset += this.transformations.length + this.transformations = [] + } } diff --git a/src/Lookup/ColumnBinarySearch.ts b/src/Lookup/ColumnBinarySearch.ts index 458070687..cf034d11c 100644 --- a/src/Lookup/ColumnBinarySearch.ts +++ b/src/Lookup/ColumnBinarySearch.ts @@ -53,6 +53,16 @@ export class ColumnBinarySearch extends AdvancedFind implements ColumnSearchStra public removeValues(range: IterableIterator<[RawScalarValue, SimpleCellAddress]>): void { } + /** + * No-op: ColumnBinarySearch reads cell values directly from the dependency graph + * on every lookup, so it has no cached data that could become stale. + * Unlike ColumnIndex, which maintains a separate value-to-address index that + * must be kept in sync with lazy transformations, binary search always operates + * on the current graph state. + */ + public forceApplyPostponedTransformations(): void { + } + /* * WARNING: Finding lower/upper bounds in unordered ranges is not supported. When ordering === 'none', assumes matchExactly === true */ diff --git a/src/Lookup/ColumnIndex.ts b/src/Lookup/ColumnIndex.ts index ba6a3c920..d7c32e589 100644 --- a/src/Lookup/ColumnIndex.ts +++ b/src/Lookup/ColumnIndex.ts @@ -184,6 +184,24 @@ export class ColumnIndex implements ColumnSearchStrategy { this.index.delete(sheetId) } + /** + * Forces all ValueIndex entries to apply any pending lazy transformations, + * bringing every entry up to the current LazilyTransformingAstService version. + * Must be called before compacting LazilyTransformingAstService. + */ + public forceApplyPostponedTransformations(): void { + for (const [sheet, sheetIndex] of this.index) { + sheetIndex.forEach((columnMap, col) => { + if (!columnMap) { + return + } + for (const value of columnMap.keys()) { + this.ensureRecentData(sheet, col, value) + } + }) + } + } + public getColumnMap(sheet: number, col: number): ColumnMap { if (!this.index.has(sheet)) { this.index.set(sheet, []) diff --git a/src/Lookup/SearchStrategy.ts b/src/Lookup/SearchStrategy.ts index a12a48fc6..2316e586c 100644 --- a/src/Lookup/SearchStrategy.ts +++ b/src/Lookup/SearchStrategy.ts @@ -51,6 +51,13 @@ export interface ColumnSearchStrategy extends SearchStrategy { moveValues(range: IterableIterator<[RawScalarValue, SimpleCellAddress]>, toRight: number, toBottom: number, toSheet: number): void, removeValues(range: IterableIterator<[RawScalarValue, SimpleCellAddress]>): void, + + /** + * Forces all lazily-tracked ValueIndex entries to apply any pending transformations, + * bringing every entry's version up to the current LazilyTransformingAstService version. + * Must be called before compacting LazilyTransformingAstService. + */ + forceApplyPostponedTransformations(): void, } export function buildColumnSearchStrategy(dependencyGraph: DependencyGraph, config: Config, statistics: Statistics): ColumnSearchStrategy { diff --git a/src/Operations.ts b/src/Operations.ts index 27f45c2d9..6a0e5ccdd 100644 --- a/src/Operations.ts +++ b/src/Operations.ts @@ -737,8 +737,14 @@ export class Operations { return changes } + /** + * Forces all formula vertices and column index entries to apply pending lazy + * transformations, bringing them up to the current LazilyTransformingAstService version. + * Called before undo of move operations and before compaction. + */ public forceApplyPostponedTransformations(): void { this.dependencyGraph.forceApplyPostponedTransformations() + this.columnSearch.forceApplyPostponedTransformations() } /** diff --git a/src/UndoRedo.ts b/src/UndoRedo.ts index 5413a79d5..87ab06439 100644 --- a/src/UndoRedo.ts +++ b/src/UndoRedo.ts @@ -22,12 +22,26 @@ export interface UndoEntry { doUndo(undoRedo: UndoRedo): void, doRedo(undoRedo: UndoRedo): void, + + /** + * Returns the LazilyTransformingAstService version keys referenced by this entry's oldData storage. + * Used to clean up oldData when the entry is permanently evicted from the undo/redo stack. + */ + getReferencedOldDataVersions(): number[], } export abstract class BaseUndoEntry implements UndoEntry { abstract doUndo(undoRedo: UndoRedo): void abstract doRedo(undoRedo: UndoRedo): void + + /** + * Returns LazilyTransformingAstService version keys referenced by this entry's oldData. + * Default implementation returns empty — override in entries that store oldData. + */ + public getReferencedOldDataVersions(): number[] { + return [] + } } export class RemoveRowsUndoEntry extends BaseUndoEntry { @@ -45,6 +59,10 @@ export class RemoveRowsUndoEntry extends BaseUndoEntry { public doRedo(undoRedo: UndoRedo): void { undoRedo.redoRemoveRows(this) } + + public getReferencedOldDataVersions(): number[] { + return this.rowsRemovals.filter(r => r.version > 0).map(r => r.version - 1) + } } export class MoveCellsUndoEntry extends BaseUndoEntry { @@ -67,6 +85,10 @@ export class MoveCellsUndoEntry extends BaseUndoEntry { public doRedo(undoRedo: UndoRedo): void { undoRedo.redoMoveCells(this) } + + public getReferencedOldDataVersions(): number[] { + return this.version > 0 ? [this.version - 1] : [] + } } export class AddRowsUndoEntry extends BaseUndoEntry { @@ -162,6 +184,10 @@ export class MoveRowsUndoEntry extends BaseUndoEntry { public doRedo(undoRedo: UndoRedo): void { undoRedo.redoMoveRows(this) } + + public getReferencedOldDataVersions(): number[] { + return this.version > 0 ? [this.version - 1] : [] + } } export class MoveColumnsUndoEntry extends BaseUndoEntry { @@ -187,6 +213,10 @@ export class MoveColumnsUndoEntry extends BaseUndoEntry { public doRedo(undoRedo: UndoRedo): void { undoRedo.redoMoveColumns(this) } + + public getReferencedOldDataVersions(): number[] { + return this.version > 0 ? [this.version - 1] : [] + } } export class AddColumnsUndoEntry extends BaseUndoEntry { @@ -220,6 +250,10 @@ export class RemoveColumnsUndoEntry extends BaseUndoEntry { public doRedo(undoRedo: UndoRedo): void { undoRedo.redoRemoveColumns(this) } + + public getReferencedOldDataVersions(): number[] { + return this.columnsRemovals.filter(r => r.version > 0).map(r => r.version - 1) + } } export class AddSheetUndoEntry extends BaseUndoEntry { @@ -286,6 +320,10 @@ export class RenameSheetUndoEntry extends BaseUndoEntry { public doRedo(undoRedo: UndoRedo): void { undoRedo.redoRenameSheet(this) } + + public getReferencedOldDataVersions(): number[] { + return (this.version !== undefined && this.version > 0) ? [this.version - 1] : [] + } } export class ClearSheetUndoEntry extends BaseUndoEntry { @@ -421,8 +459,44 @@ export class BatchUndoEntry extends BaseUndoEntry { public doRedo(undoRedo: UndoRedo): void { undoRedo.redoBatch(this) } + + public getReferencedOldDataVersions(): number[] { + return this.operations.flatMap(op => op.getReferencedOldDataVersions()) + } } +/** + * Manages undo/redo stacks for all spreadsheet operations. + * + * ## oldData: Preserving Formula ASTs Across Irreversible Transformations + * + * Some structural operations (e.g., removing rows/columns, moving cells) destroy + * formula information that cannot be reconstructed from the transformation alone. + * For example, when a row is removed, formulas referencing that row are rewritten + * to `#REF!` — an irreversible change. + * + * To support undo of such operations, `oldData` stores snapshots of formula AST + * hashes keyed by the LazilyTransformingAstService version at which the irreversible + * transformation was applied. Each entry maps a version number to an array of + * `[cellAddress, astHash]` pairs that can be used to restore the original formula + * from the parser cache. + * + * ### Memory Management + * + * Without cleanup, `oldData` grows indefinitely as undo entries are evicted but + * their oldData keys remain. Three mechanisms prevent this: + * + * 1. **Eviction cleanup**: When undo entries are evicted (due to `undoLimit`), + * `cleanupOldDataForEntries()` deletes their referenced oldData keys + * (unless still needed by entries on the other stack). + * 2. **Orphan cleanup**: Compaction may force lazy formula evaluation, which + * writes new oldData entries for already-evicted undo entries. After compaction, + * `cleanupOrphanedOldData()` removes any keys not referenced by entries on + * either stack or the in-progress batch. + * 3. **Short-circuit**: When `undoLimit` is 0 (undo disabled), + * `storeDataForVersion()` returns immediately to avoid storing data that + * would never be used. + */ export class UndoRedo { public oldData: Map = new Map() private undoStack: UndoEntry[] = [] @@ -457,7 +531,14 @@ export class UndoRedo { this.batchUndoEntry = undefined } + /** + * Stores a formula AST hash snapshot for the given LazilyTransformingAstService version. + * Skipped when `undoLimit` is 0 (undo disabled) to avoid storing data that would never be used. + */ public storeDataForVersion(version: number, address: SimpleCellAddress, astHash: string) { + if (this.undoLimit === 0) { + return + } if (!this.oldData.has(version)) { this.oldData.set(version, []) } @@ -465,11 +546,15 @@ export class UndoRedo { currentOldData.push([address, astHash]) } + /** Clears the redo stack and removes oldData entries no longer referenced by any remaining entry. */ public clearRedoStack() { + this.cleanupOldDataForEntries(this.redoStack, this.undoStack) this.redoStack = [] } + /** Clears the undo stack and removes oldData entries no longer referenced by any remaining entry. */ public clearUndoStack() { + this.cleanupOldDataForEntries(this.undoStack, this.redoStack) this.undoStack = [] } @@ -565,12 +650,14 @@ export class UndoRedo { } public undoMoveRows(operation: MoveRowsUndoEntry) { + this.operations.forceApplyPostponedTransformations() const {sheet} = operation this.operations.moveRows(sheet, operation.undoStart, operation.numberOfRows, operation.undoEnd) this.restoreOldDataFromVersion(operation.version - 1) } public undoMoveColumns(operation: MoveColumnsUndoEntry) { + this.operations.forceApplyPostponedTransformations() const {sheet} = operation this.operations.moveColumns(sheet, operation.undoStart, operation.numberOfColumns, operation.undoEnd) this.restoreOldDataFromVersion(operation.version - 1) @@ -771,9 +858,56 @@ export class UndoRedo { this.operations.setColumnOrder(operation.sheetId, operation.columnMapping) } + /** + * Adds an entry to the undo stack, evicting the oldest entries when undoLimit is exceeded. + * Evicted entries have their oldData keys cleaned up to prevent memory leaks. + */ private addUndoEntry(operation: UndoEntry) { this.undoStack.push(operation) - this.undoStack.splice(0, Math.max(0, this.undoStack.length - this.undoLimit)) + const evictCount = Math.max(0, this.undoStack.length - this.undoLimit) + if (evictCount > 0) { + const evicted = this.undoStack.splice(0, evictCount) + this.cleanupOldDataForEntries(evicted, [...this.undoStack, ...this.redoStack]) + } + } + + /** + * Removes oldData entries whose version keys are not referenced by any + * entry on the undo stack, redo stack, or in-progress batch. Called after + * compaction forces lazy formula evaluation, which may insert oldData for + * already-evicted entries. + */ + public cleanupOrphanedOldData() { + const referencedVersions = this.collectReferencedOldDataVersions(this.undoStack, this.redoStack) + for (const version of this.oldData.keys()) { + if (!referencedVersions.has(version)) { + this.oldData.delete(version) + } + } + } + + /** + * Removes oldData entries referenced by permanently discarded undo/redo entries, + * but only if not still referenced by entries remaining on the other stack or + * in-progress batch. + */ + private cleanupOldDataForEntries(discardedEntries: UndoEntry[], retainedEntries: UndoEntry[]) { + const retainedVersions = this.collectReferencedOldDataVersions(retainedEntries) + for (const entry of discardedEntries) { + for (const version of entry.getReferencedOldDataVersions()) { + if (!retainedVersions.has(version)) { + this.oldData.delete(version) + } + } + } + } + + /** Collects all oldData version keys referenced by the given entry lists and in-progress batch. */ + private collectReferencedOldDataVersions(...entryLists: UndoEntry[][]): Set { + return new Set([ + ...entryLists.flatMap(list => list.flatMap(e => e.getReferencedOldDataVersions())), + ...(this.batchUndoEntry?.getReferencedOldDataVersions() ?? []), + ]) } private undoEntry(operation: UndoEntry) { From ca0bb89d13671e2e5113185b6cd70216052fb73d Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 3 Apr 2026 13:39:17 +0200 Subject: [PATCH 09/20] Add SEQUENCE built-in function (#1645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem HyperFormula was missing the SEQUENCE dynamic array function for generating sequential number arrays. ## Fix Implements `SEQUENCE(rows, [cols], [start], [step])` as a new `SequencePlugin`: - Returns a rows×cols array of sequential numbers, filled row-major - Parse-time array size prediction via `sequenceArraySize()` — handles NUMBER and STRING literals; non-literal args (cell refs, formulas) return `#VALUE!` (architectural limitation: array size must be known at parse time) - Error types match Excel: negative dims → `#VALUE!`, zero dims → `#NUM!` (mapped from Excel's `#CALC!`) - `emptyAsDefault: true` on optional params — empty args like `=SEQUENCE(3,,,)` use declared defaults - i18n for all 17 languages with proper Excel-localized names ## Changed files | File | Change | |------|--------| | `src/interpreter/plugin/SequencePlugin.ts` | New plugin: `sequence()` + `sequenceArraySize()` | | `src/interpreter/plugin/index.ts` | Plugin registration | | `src/i18n/languages/*.ts` (17 files) | SEQUENCE translations | | `docs/guide/built-in-functions.md` | SEQUENCE row in Array functions table | | `docs/guide/release-notes.md` | Unreleased section | | `CHANGELOG.md` | Added entry | | `test/smoke.spec.ts` | 3 smoke tests | | `test/fetch-tests.sh` | Robustness fix for `git pull` | ## Tests Regression tests in `handsontable/hyperformula-tests` (branch `feature/SEQUENCE`): | Group | Tests | Coverage | |-------|-------|----------| | Core sanity | #1–#8 | Basic usage, MS docs examples | | Default parameters | #9–#13 | Omitted cols/start/step | | Empty args | #14–#21 | emptyAsDefault behavior | | Step variants | #22–#28 | Zero, negative, fractional step | | Truncation | #29–#35 | Fractional dims, trunc-to-zero | | Error conditions | #36–#48 | Zero/negative dims, text, arity, propagation | | Type coercion | #49–#59 | Booleans, strings, cell refs, empty cells | | Large sequences | #60–#63 | 100×100, 1000×1, 1×1000 | | Fill order | #64–#69 | Row-major verification | | Function combos | #70–#74 | SUM, AVERAGE, MAX, MIN, COUNT | | Behavioral | #75–#80 | Max dims, spill | | Dynamic args | #81–#82 | Architectural limitation (cell ref → #VALUE!) | - 82/82 PASS confirmed in Excel desktop (Microsoft 365) - 3 smoke tests in `test/smoke.spec.ts` --- > [!NOTE] > **Medium Risk** > Adds a new array-producing built-in (`SEQUENCE`) with parse-time size prediction rules; mistakes here can affect array vertex creation and spill/error behavior across formulas. Remaining changes are documentation/i18n updates plus a minor test script tweak. > > **Overview** > Adds the `SEQUENCE(rows, [cols], [start], [step])` built-in via a new `SequencePlugin`, generating row-major numeric arrays and enforcing dimension/max-sheet limits with appropriate errors. > > Introduces parse-time result sizing (`sequenceArraySize`) that only accepts literal `rows`/`cols` (non-literal dimensions now yield `#VALUE!` due to unknown output size), and wires the plugin into the interpreter exports. > > Updates changelog and docs to list `SEQUENCE`, adds function name translations across all language packs, and adjusts `test/fetch-tests.sh` to pull explicitly from `origin` for the current branch. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b08cd795c3e1daf4460aa136f66aab78e0f3477a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude --- CHANGELOG.md | 1 + docs/guide/built-in-functions.md | 1 + docs/guide/known-limitations.md | 1 + docs/guide/list-of-differences.md | 1 + src/i18n/languages/csCZ.ts | 1 + src/i18n/languages/daDK.ts | 1 + src/i18n/languages/deDE.ts | 1 + src/i18n/languages/enGB.ts | 1 + src/i18n/languages/esES.ts | 1 + src/i18n/languages/fiFI.ts | 1 + src/i18n/languages/frFR.ts | 1 + src/i18n/languages/huHU.ts | 1 + src/i18n/languages/itIT.ts | 1 + src/i18n/languages/nbNO.ts | 1 + src/i18n/languages/nlNL.ts | 1 + src/i18n/languages/plPL.ts | 1 + src/i18n/languages/ptPT.ts | 1 + src/i18n/languages/ruRU.ts | 1 + src/i18n/languages/svSE.ts | 1 + src/i18n/languages/trTR.ts | 1 + src/interpreter/plugin/SequencePlugin.ts | 176 +++++++++++++++++++++++ src/interpreter/plugin/index.ts | 1 + test/fetch-tests.sh | 2 +- 23 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/interpreter/plugin/SequencePlugin.ts 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 From 357eee3b2d42db9959979a67b3c17538de0ab53c Mon Sep 17 00:00:00 2001 From: GreenFlux Date: Thu, 9 Apr 2026 11:15:30 -0400 Subject: [PATCH 10/20] Add AI integration docs and reorganize sidebar (#1644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add three new landing pages under Framework integration: HyperFormula AI SDK, Integration with LangChain/LangGraph, and HyperFormula MCP Server - Rename sidebar "Overview" section to "About" and move it above Miscellaneous (2nd to last) - Promote "Getting started" to second position in sidebar (right after Introduction) ## Test plan - [ ] Run `npm run docs:dev` and verify sidebar order: Introduction → Getting started → Framework integration → ... → About → Miscellaneous - [ ] Verify new pages render at `/guide/ai-sdk`, `/guide/integration-with-langchain`, `/guide/mcp-server` - [ ] Verify existing Overview pages (Quality, Supported browsers, etc.) still accessible at original URLs --- > [!NOTE] > **Low Risk** > Low risk: documentation-only changes that add new guide pages and reorder sidebar navigation without affecting runtime code. > > **Overview** > Adds three new guide pages describing AI-focused integrations: `ai-sdk`, `integration-with-langchain`, and `mcp-server`. > > Reorganizes the VuePress sidebar by renaming the prior *Overview* section to **About**, moving it near the end, and renaming *Framework integration* to **Integrations** while linking in the new AI docs pages. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1688f2376dc422874d9ba1eece6e0ae7965547f7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Kuba Sekowski --- docs/.vuepress/config.js | 27 ++++++------ docs/guide/ai-sdk.md | 51 +++++++++++++++++++++++ docs/guide/integration-with-langchain.md | 53 ++++++++++++++++++++++++ docs/guide/mcp-server.md | 44 ++++++++++++++++++++ 4 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 docs/guide/ai-sdk.md create mode 100644 docs/guide/integration-with-langchain.md create mode 100644 docs/guide/mcp-server.md diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 46e040b9a..70456f0da 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -175,17 +175,6 @@ module.exports = { ['/guide/demo', 'Demo'], ] }, - { - title: 'Overview', - collapsable: false, - children: [ - ['/guide/quality', 'Quality'], - ['/guide/supported-browsers', 'Supported browsers'], - ['/guide/dependencies', 'Dependencies'], - ['/guide/licensing', 'Licensing'], - ['/guide/support', 'Support'], - ] - }, { title: 'Getting started', collapsable: false, @@ -199,13 +188,16 @@ module.exports = { ] }, { - title: 'Framework integration', + title: 'Integrations', collapsable: false, children: [ ['/guide/integration-with-react', 'Integration with React'], ['/guide/integration-with-vue', 'Integration with Vue'], ['/guide/integration-with-angular', 'Integration with Angular'], ['/guide/integration-with-svelte', 'Integration with Svelte'], + ['/guide/ai-sdk', 'HyperFormula AI SDK'], + ['/guide/integration-with-langchain', 'Integration with LangChain'], + ['/guide/mcp-server', 'HyperFormula MCP Server'], ] }, { @@ -276,6 +268,17 @@ module.exports = { ['/guide/migration-from-2.x-to-3.0', 'Migrating from 2.x to 3.0'], ] }, + { + title: 'About', + collapsable: false, + children: [ + ['/guide/quality', 'Quality'], + ['/guide/supported-browsers', 'Supported browsers'], + ['/guide/dependencies', 'Dependencies'], + ['/guide/licensing', 'Licensing'], + ['/guide/support', 'Support'], + ] + }, { title: 'Miscellaneous', collapsable: false, diff --git a/docs/guide/ai-sdk.md b/docs/guide/ai-sdk.md new file mode 100644 index 000000000..aeb93e6ca --- /dev/null +++ b/docs/guide/ai-sdk.md @@ -0,0 +1,51 @@ +# HyperFormula AI SDK + +Let LLMs safely read/write spreadsheets and compute formulas via a deterministic engine. + +## What it does + +- **Evaluate formulas on the fly** —call `calculateFormula()` to evaluate any Excel-compatible formula without placing it in a cell. +- **Read and write cells and ranges** —get or set individual cells and multi-cell ranges so an LLM can inspect, populate, or modify sheet data programmatically. +- **Trace dependencies** —call `getCellDependents()` and `getCellPrecedents()` to understand which cells feed into a formula and what downstream values would change. + +## Quickstart + +```js +import HyperFormula from 'hyperformula'; +import { createSpreadsheetTools } from 'hyperformula/ai'; + +// 1. Create a HyperFormula instance with initial data +const hf = HyperFormula.buildFromArray([ + ['Revenue', 100], + ['Cost', 60], + ['Profit', '=B1-B2'], +]); + +// 2. Create tools your LLM agent can call +const tools = createSpreadsheetTools(hf); + +// 3. Agent interaction examples +tools.evaluate({ formula: '=IRR({-1000,300,400,500,200})' }); +// → 0.1189 — deterministic, no LLM math + +tools.setCellContents({ sheet: 0, col: 1, row: 0, value: 200 }); +tools.getRange({ sheet: 0, startCol: 0, startRow: 0, endCol: 1, endRow: 2 }); +// → [['Revenue', 200], ['Cost', 60], ['Profit', 140]] + +// Agent: "What drives the profit number?" +tools.getDependents({ sheet: 0, col: 1, row: 0 }); +// → [{ sheet: 0, col: 1, row: 2 }] — Revenue flows into Profit +``` + +## Use cases + +- **Explain a sheet** —ask an agent to summarize what a spreadsheet does, which cells are inputs, and how outputs are derived. +- **Generate a what-if scenario** —let the model tweak assumptions (price, volume, rate) and observe how results change in real time. +- **Validate and clean data** —have the agent scan ranges for errors, missing values, or inconsistencies and fix them with formulas or direct edits. +- **Create formulas from natural language** —describe a calculation in plain English and let the model write and verify the correct Excel formula. + +## Beta access + +::: tip +[Sign up for beta access](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) +::: diff --git a/docs/guide/integration-with-langchain.md b/docs/guide/integration-with-langchain.md new file mode 100644 index 000000000..cbb695dcd --- /dev/null +++ b/docs/guide/integration-with-langchain.md @@ -0,0 +1,53 @@ +# Integration with LangChain/LangGraph + +A LangChain/LangGraph tool that gives AI agents deterministic, Excel-compatible formula evaluation instead of relying on LLM-generated math. + +## What it does + +**Without HyperFormula:** + +```python +result = llm.invoke( + "Calculate the IRR for these cash flows: [-1000, 300, 400, 500, 200]" +) +# "The IRR is approximately 12.4%" ← non-deterministic, unverifiable +``` + +**With HyperFormula tool:** + +```python +from langchain_core.tools import tool +from hyperformula import HyperFormula + +hf = HyperFormula.build_from_array([[-1000, 300, 400, 500, 200]]) + +@tool +def evaluate_formula(formula: str) -> str: + """Evaluate an Excel-compatible formula using HyperFormula.""" + return hf.calculate_formula(formula, sheet_id=0) + +agent = create_react_agent(llm, [evaluate_formula]) + +# Agent calls: evaluate_formula("=IRR(A1:E1)") +# → 0.1189 ← deterministic, auditable +``` + +## How it works + +1. **Agent populates a HyperFormula sheet** —writes data and formulas (`=SUM`, `=IF`, `=VLOOKUP`, etc.) into cells. +2. **HyperFormula evaluates deterministically** —resolves the full dependency graph using 400+ built-in functions. No LLM in the loop for math. +3. **Agent continues with verified data** —computed values flow back into the chain for reasoning, reporting, or downstream actions. + +## Use cases + +- Financial modeling (NPV, IRR, amortization) +- Data transformation and aggregation (SUMIF, VLOOKUP) +- Dynamic pricing with formula-defined logic +- What-if scenarios and forecasting +- Report generation with verified KPIs + +## Beta access + +::: tip +[Sign up for beta access](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) +::: diff --git a/docs/guide/mcp-server.md b/docs/guide/mcp-server.md new file mode 100644 index 000000000..618323f22 --- /dev/null +++ b/docs/guide/mcp-server.md @@ -0,0 +1,44 @@ +# HyperFormula MCP Server + +An MCP (Model Context Protocol) server that exposes HyperFormula as a tool for any MCP-compatible AI client, giving LLMs deterministic spreadsheet computation. + +## What it does + +- **Evaluate formulas** —any MCP client can call HyperFormula to evaluate Excel-compatible formulas and get exact results. +- **Read and write cells** —get or set individual cell values and ranges through standard MCP tool calls. +- **Inspect dependencies** —trace which cells a formula depends on and understand the calculation graph. + +**Without HyperFormula:** + +``` +User: What's the NPV at 8% for these cash flows? +Agent: "Approximately $142.50" ← non-deterministic, unverifiable +``` + +**With HyperFormula MCP server:** + +``` +User: What's the NPV at 8% for these cash flows? +Agent → tool call: evaluate("=NPV(0.08, B1:B5)") +Agent: "$138.43" ← deterministic, auditable +``` + +## How it works + +1. **Start the MCP server** —runs HyperFormula as a local MCP server that any compatible client (Claude Desktop, Cursor, VS Code, etc.) can connect to. +2. **Client sends tool calls** —the AI client calls tools like `evaluate`, `getCellValue`, and `setCellContents` via the MCP protocol. +3. **HyperFormula evaluates deterministically** —resolves formulas using 400+ built-in functions with full dependency tracking. No LLM in the loop for math. +4. **Results flow back to the client** —computed values return through MCP, grounding the AI's response in verified numbers. + +## Use cases + +- Spreadsheet Q&A in Claude Desktop or other MCP clients +- Formula evaluation in IDE-based AI assistants +- Financial calculations in chat-based agent workflows +- Data validation and transformation via natural language + +## Beta access + +::: tip +[Sign up for beta access](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) +::: From 736235e3808b7fca1c23dc88e91f997d0a27500d Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Tue, 14 Apr 2026 09:26:59 +0200 Subject: [PATCH 11/20] Add hardcoded trial license key (#1649) ### Context https://app.clickup.com/t/9015210959/HF-116 ### How did you test your changes? unit tests ### Types of changes - [ ] Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore) - [x] 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 ### Checklist: - [ ] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/guide/contributing.html) and I confirm that my code follows the code style of this project. - [ ] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [ ] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. - [ ] My change is compatible with Microsoft Excel. - [ ] My change is compatible with Google Sheets. - [ ] I described my changes in the [CHANGELOG.md](https://github.com/handsontable/hyperformula/blob/master/CHANGELOG.md) file. - [ ] My changes require a documentation update. - [ ] My changes require a migration guide. --- > [!NOTE] > **High Risk** > Updates license validation logic, a compliance-critical area, by adding a hardcoded key that will always be treated as valid; mistakes here could unintentionally bypass licensing checks. > > **Overview** > Adds a new hardcoded trial license key (`hftrial-0168e-1f2b7-47158-70b05-0842f`) to the whitelist in `checkLicenseKeyValidity`, causing that exact value to be treated as `valid` without schema/expiry checks. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fd095ae3332ca09712fa75a4ceb6b8d90cfb5305. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- src/helpers/licenseKeyValidator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index 0f61ba8de..72ae00324 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -54,7 +54,7 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS vars: {}, } - if (licenseKey === 'gpl-v3' || licenseKey === 'internal-use-in-handsontable') { + if (licenseKey === 'gpl-v3' || licenseKey === 'internal-use-in-handsontable' || licenseKey === 'hftrial-0168e-1f2b7-47158-70b05-0842f') { messageDescriptor.template = LicenseKeyValidityState.VALID } else if (typeof licenseKey === 'string' && checkKeySchema(licenseKey)) { From 1852159ec8b4c82107366b18fbd86d27735a452b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 16 Apr 2026 15:11:59 +0700 Subject: [PATCH 12/20] Add PERCENTILE and QUARTILE function families (#1650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Implement 6 new functions: PERCENTILE, PERCENTILE.INC, PERCENTILE.EXC, QUARTILE, QUARTILE.INC, QUARTILE.EXC - New `PercentilePlugin` with inclusive/exclusive interpolation helpers - i18n translations for all 17 languages (verified against Excel function translator) - CHANGELOG entry and built-in-functions.md updated ## Changes - `src/interpreter/plugin/PercentilePlugin.ts` — new plugin - `src/interpreter/plugin/index.ts` — export registration - `src/i18n/languages/*.ts` — all 17 languages - `docs/guide/built-in-functions.md` — 6 new entries (alphabetical) - `CHANGELOG.md` — added entry ## Test plan - [ ] 57 unit tests in hyperformula-tests (function-percentile.spec.ts) - [ ] Excel validation workbook (107 cases) — open in Excel 365 desktop, verify all PASS - [ ] `npm run lint` passes - [ ] `npm run compile` passes - [ ] CI green --- > [!NOTE] > **Medium Risk** > Introduces new statistical function implementations and aliases in the interpreter; main risk is correctness/edge-case parity with spreadsheet semantics and potential impacts to function translation tables. > > **Overview** > Adds `PERCENTILE`/`QUARTILE` function families, including `.INC` and `.EXC` variants, via a new `PercentilePlugin` that computes percentiles/quartiles with linear interpolation and appropriate `#NUM!` error handling for out-of-range inputs. > > Registers the plugin export, adds function aliases (`PERCENTILE`→`PERCENTILE.INC`, `QUARTILE`→`QUARTILE.INC`), and updates built-in function documentation, changelog, and all language packs to include translations for the new function names. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7172a528315254c276084f9cfcaff60b51dc8f48. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Kuba Sekowski --- CHANGELOG.md | 1 + docs/guide/built-in-functions.md | 6 + src/i18n/languages/csCZ.ts | 6 + src/i18n/languages/daDK.ts | 6 + src/i18n/languages/deDE.ts | 6 + src/i18n/languages/enGB.ts | 6 + src/i18n/languages/esES.ts | 6 + src/i18n/languages/fiFI.ts | 6 + src/i18n/languages/frFR.ts | 6 + src/i18n/languages/huHU.ts | 6 + src/i18n/languages/itIT.ts | 6 + src/i18n/languages/nbNO.ts | 6 + src/i18n/languages/nlNL.ts | 6 + src/i18n/languages/plPL.ts | 6 + src/i18n/languages/ptPT.ts | 6 + src/i18n/languages/ruRU.ts | 6 + src/i18n/languages/svSE.ts | 6 + src/i18n/languages/trTR.ts | 6 + src/interpreter/plugin/PercentilePlugin.ts | 225 +++++++++++++++++++++ src/interpreter/plugin/index.ts | 1 + 20 files changed, 329 insertions(+) create mode 100644 src/interpreter/plugin/PercentilePlugin.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ced636c78..21858277a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Added new functions: PERCENTILE, PERCENTILE.INC, PERCENTILE.EXC, QUARTILE, QUARTILE.INC, QUARTILE.EXC. [#1650](https://github.com/handsontable/hyperformula/pull/1650) - 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) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index 241d75185..e1ad66821 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -436,9 +436,15 @@ Total number of functions: **{{ $page.functionsCount }}** | NORMSINV | Returns value of inverse normal distribution. | NORMSINV(P) | | PEARSON | Returns the correlation coefficient between two data sets. | PEARSON(Data1, Data2) | | PHI | Returns probability densitity of normal distribution. | PHI(X) | +| PERCENTILE | Returns the k-th percentile of values in a range, inclusive of 0 and 1. | PERCENTILE(Data, K) | +| PERCENTILE.EXC | Returns the k-th percentile of values in a range, exclusive of 0 and 1. | PERCENTILE.EXC(Data, K) | +| PERCENTILE.INC | Returns the k-th percentile of values in a range, inclusive of 0 and 1. | PERCENTILE.INC(Data, K) | | POISSON | Returns density of Poisson distribution. | POISSON(X, Mean, Mode) | | POISSON.DIST | Returns density of Poisson distribution. | POISSON.DIST(X, Mean, Mode) | | POISSONDIST | Returns density of Poisson distribution. | POISSONDIST(X, Mean, Mode) | +| QUARTILE | Returns the quartile of a data set, based on inclusive percentile values. | QUARTILE(Data, Quart) | +| QUARTILE.EXC | Returns the quartile of a data set, based on exclusive percentile values. | QUARTILE.EXC(Data, Quart) | +| QUARTILE.INC | Returns the quartile of a data set, based on inclusive percentile values. | QUARTILE.INC(Data, Quart) | | RSQ | Returns the squared correlation coefficient between two data sets. | RSQ(Data1, Data2) | | SKEW | Returns skeweness of a sample. | SKEW(Number1, Number2, ...NumberN) | | SKEW.P | Returns skeweness of a population. | SKEW.P(Number1, Number2, ...NumberN) | diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 2e521df6e..463bb1d2a 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'IMTAN', LARGE: 'LARGE', SMALL: 'SMALL', + PERCENTILE: 'PERCENTIL', + 'PERCENTILE.INC': 'PERCENTIL.INC', + 'PERCENTILE.EXC': 'PERCENTIL.EXC', + QUARTILE: 'QUARTIL', + 'QUARTILE.INC': 'QUARTIL.INC', + 'QUARTILE.EXC': 'QUARTIL.EXC', AVEDEV: 'PRŮMODCHYLKA', CONFIDENCE: 'CONFIDENCE', 'CONFIDENCE.NORM': 'CONFIDENCE.NORM', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 3d3962795..6525c442a 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'IMAGTAN', LARGE: 'STØRSTE', SMALL: 'MINDSTE', + PERCENTILE: 'FRAKTIL', + 'PERCENTILE.INC': 'FRAKTIL.MEDTAG', + 'PERCENTILE.EXC': 'FRAKTIL.UDELAD', + QUARTILE: 'KVARTIL', + 'QUARTILE.INC': 'KVARTIL.MEDTAG', + 'QUARTILE.EXC': 'KVARTIL.UDELAD', AVEDEV: 'MAD', CONFIDENCE: 'KONFIDENSINTERVAL', 'CONFIDENCE.NORM': 'KONFIDENS.NORM', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index b50393bc4..d99d6cc1c 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'IMATAN', LARGE: 'KGRÖSSTE', SMALL: 'KKLEINSTE', + PERCENTILE: 'QUANTIL', + 'PERCENTILE.INC': 'QUANTIL.INKL', + 'PERCENTILE.EXC': 'QUANTIL.EXKL', + QUARTILE: 'QUARTILE', + 'QUARTILE.INC': 'QUARTILE.INKL', + 'QUARTILE.EXC': 'QUARTILE.EXKL', AVEDEV: 'MITTELABW', CONFIDENCE: 'KONFIDENZ', 'CONFIDENCE.NORM': 'KONFIDENZ.NORM', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 7bcf2ee0b..840e14a90 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'IMTAN', LARGE: 'LARGE', SMALL: 'SMALL', + PERCENTILE: 'PERCENTILE', + 'PERCENTILE.INC': 'PERCENTILE.INC', + 'PERCENTILE.EXC': 'PERCENTILE.EXC', + QUARTILE: 'QUARTILE', + 'QUARTILE.INC': 'QUARTILE.INC', + 'QUARTILE.EXC': 'QUARTILE.EXC', AVEDEV: 'AVEDEV', CONFIDENCE: 'CONFIDENCE', 'CONFIDENCE.NORM': 'CONFIDENCE.NORM', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 217310790..1d8278ca5 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -370,6 +370,12 @@ export const dictionary: RawTranslationPackage = { IMTAN: 'IMTAN', LARGE: 'K.ESIMO.MAYOR', SMALL: 'K.ESIMO.MENOR', + PERCENTILE: 'PERCENTIL', + 'PERCENTILE.INC': 'PERCENTIL.INC', + 'PERCENTILE.EXC': 'PERCENTIL.EXC', + QUARTILE: 'CUARTIL', + 'QUARTILE.INC': 'CUARTIL.INC', + 'QUARTILE.EXC': 'CUARTIL.EXC', AVEDEV: 'DESVPROM', CONFIDENCE: 'INTERVALO.CONFIANZA', 'CONFIDENCE.NORM': 'INTERVALO.CONFIANZA.NORM', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index c2cc74745..cd11bc7ec 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'KOMPLEKSI.TAN', LARGE: 'SUURI', SMALL: 'PIENI', + PERCENTILE: 'PROSENTTIPISTE', + 'PERCENTILE.INC': 'PROSENTTIPISTE.SIS', + 'PERCENTILE.EXC': 'PROSENTTIPISTE.ULK', + QUARTILE: 'NELJÄNNES', + 'QUARTILE.INC': 'NELJÄNNES.SIS', + 'QUARTILE.EXC': 'NELJÄNNES.ULK', AVEDEV: 'KESKIPOIKKEAMA', CONFIDENCE: 'LUOTTAMUSVÄLI', 'CONFIDENCE.NORM': 'LUOTTAMUSVÄLI.NORM', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 1be881f80..ae1dd4389 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'COMPLEXE.TAN', LARGE: 'GRANDE.VALEUR', SMALL: 'PETITE.VALEUR', + PERCENTILE: 'CENTILE', + 'PERCENTILE.INC': 'CENTILE.INCLURE', + 'PERCENTILE.EXC': 'CENTILE.EXCLURE', + QUARTILE: 'QUARTILE', + 'QUARTILE.INC': 'QUARTILE.INCLURE', + 'QUARTILE.EXC': 'QUARTILE.EXCLURE', AVEDEV: 'ECART.MOYEN', CONFIDENCE: 'INTERVALLE.CONFIANCE', 'CONFIDENCE.NORM': 'INTERVALLE.CONFIANCE.NORMAL', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index cc4a15edf..223f12811 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'KÉPZ.TAN', LARGE: 'NAGY', SMALL: 'KICSI', + PERCENTILE: 'PERCENTILIS', + 'PERCENTILE.INC': 'PERCENTILIS.TARTALMAZ', + 'PERCENTILE.EXC': 'PERCENTILIS.KIZÁR', + QUARTILE: 'KVARTILIS', + 'QUARTILE.INC': 'KVARTILIS.TARTALMAZ', + 'QUARTILE.EXC': 'KVARTILIS.KIZÁR', AVEDEV: 'ÁTL.ELTÉRÉS', CONFIDENCE: 'MEGBÍZHATÓSÁG', 'CONFIDENCE.NORM': 'MEGBÍZHATÓSÁG.NORM', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 34279b6cb..1ebbaa255 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'COMP.TAN', LARGE: 'GRANDE', SMALL: 'PICCOLO', + PERCENTILE: 'PERCENTILE', + 'PERCENTILE.INC': 'INC.PERCENTILE', + 'PERCENTILE.EXC': 'ESC.PERCENTILE', + QUARTILE: 'QUARTILE', + 'QUARTILE.INC': 'INC.QUARTILE', + 'QUARTILE.EXC': 'ESC.QUARTILE', AVEDEV: 'MEDIA.DEV', CONFIDENCE: 'CONFIDENZA', 'CONFIDENCE.NORM': 'CONFIDENZA.NORM', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index c8ff6b754..9ec608493 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'IMTAN', LARGE: 'N.STØRST', SMALL: 'N.MINST', + PERCENTILE: 'PERSENTIL', + 'PERCENTILE.INC': 'PERSENTIL.INK', + 'PERCENTILE.EXC': 'PERSENTIL.EKS', + QUARTILE: 'KVARTIL', + 'QUARTILE.INC': 'KVARTIL.INK', + 'QUARTILE.EXC': 'KVARTIL.EKS', AVEDEV: 'GJENNOMSNITTSAVVIK', CONFIDENCE: 'KONFIDENS', 'CONFIDENCE.NORM': 'KONFIDENS.NORM', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index d6c4a78f3..201318236 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'C.TAN', LARGE: 'GROOTSTE', SMALL: 'KLEINSTE', + PERCENTILE: 'PERCENTIEL', + 'PERCENTILE.INC': 'PERCENTIEL.INC', + 'PERCENTILE.EXC': 'PERCENTIEL.EXC', + QUARTILE: 'KWARTIEL', + 'QUARTILE.INC': 'KWARTIEL.INC', + 'QUARTILE.EXC': 'KWARTIEL.EXC', AVEDEV: 'GEM.DEVIATIE', CONFIDENCE: 'BETROUWBAARHEID', 'CONFIDENCE.NORM': 'VERTROUWELIJKHEID.NORM', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 4fb84de19..acce07114 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'TAN.LICZBY.ZESP', LARGE: 'MAX.K', SMALL: 'MIN.K', + PERCENTILE: 'PERCENTYL', + 'PERCENTILE.INC': 'PERCENTYL.PRZEDZ.ZAMK', + 'PERCENTILE.EXC': 'PERCENTYL.PRZEDZ.OTW', + QUARTILE: 'KWARTYL', + 'QUARTILE.INC': 'KWARTYL.PRZEDZ.ZAMK', + 'QUARTILE.EXC': 'KWARTYL.PRZEDZ.OTW', AVEDEV: 'ODCH.ŚREDNIE', CONFIDENCE: 'UFNOŚĆ', 'CONFIDENCE.NORM': 'UFNOŚĆ.NORM', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 63d7842d4..a161a5497 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'IMTAN', LARGE: 'MAIOR', SMALL: 'MENOR', + PERCENTILE: 'PERCENTIL', + 'PERCENTILE.INC': 'PERCENTIL.INC', + 'PERCENTILE.EXC': 'PERCENTIL.EXC', + QUARTILE: 'QUARTIL', + 'QUARTILE.INC': 'QUARTIL.INC', + 'QUARTILE.EXC': 'QUARTIL.EXC', AVEDEV: 'DESV.MÉDIO', CONFIDENCE: 'INT.CONFIANÇA', 'CONFIDENCE.NORM': 'INT.CONFIANÇA.NORM', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 49d54ca63..8d07da729 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'МНИМ.TAN', LARGE: 'НАИБОЛЬШИЙ', SMALL: 'НАИМЕНЬШИЙ', + PERCENTILE: 'ПЕРСЕНТИЛЬ', + 'PERCENTILE.INC': 'ПРОЦЕНТИЛЬ.ВКЛ', + 'PERCENTILE.EXC': 'ПРОЦЕНТИЛЬ.ИСКЛ', + QUARTILE: 'КВАРТИЛЬ', + 'QUARTILE.INC': 'КВАРТИЛЬ.ВКЛ', + 'QUARTILE.EXC': 'КВАРТИЛЬ.ИСКЛ', AVEDEV: 'СРОТКЛ', CONFIDENCE: 'ДОВЕРИТ', 'CONFIDENCE.NORM': 'ДОВЕРИТ.НОРМ', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index c48780571..c92269c33 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'IMTAN', LARGE: 'STÖRSTA', SMALL: 'MINSTA', + PERCENTILE: 'PERCENTIL', + 'PERCENTILE.INC': 'PERCENTIL.INK', + 'PERCENTILE.EXC': 'PERCENTIL.EXK', + QUARTILE: 'KVARTIL', + 'QUARTILE.INC': 'KVARTIL.INK', + 'QUARTILE.EXC': 'KVARTIL.EXK', AVEDEV: 'MEDELAVV', CONFIDENCE: 'KONFIDENS', 'CONFIDENCE.NORM': 'KONFIDENS.NORM', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 26455f725..417b96bb9 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -370,6 +370,12 @@ const dictionary: RawTranslationPackage = { IMTAN: 'SANTAN', LARGE: 'BÜYÜK', SMALL: 'KÜÇÜK', + PERCENTILE: 'YÜZDEBİRLİK', + 'PERCENTILE.INC': 'YÜZDEBİRLİK.DHL', + 'PERCENTILE.EXC': 'YÜZDEBİRLİK.HRC', + QUARTILE: 'DÖRTTEBİRLİK', + 'QUARTILE.INC': 'DÖRTTEBİRLİK.DHL', + 'QUARTILE.EXC': 'DÖRTTEBİRLİK.HRC', AVEDEV: 'ORTSAP', CONFIDENCE: 'GÜVENİRLİK', 'CONFIDENCE.NORM': 'GÜVENİLİRLİK.NORM', diff --git a/src/interpreter/plugin/PercentilePlugin.ts b/src/interpreter/plugin/PercentilePlugin.ts new file mode 100644 index 000000000..a09cd2c5c --- /dev/null +++ b/src/interpreter/plugin/PercentilePlugin.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {CellError, ErrorType} from '../../Cell' +import {ErrorMessage} from '../../error-message' +import {ProcedureAst} from '../../parser' +import {InterpreterState} from '../InterpreterState' +import {InterpreterValue} from '../InterpreterValue' +import {SimpleRangeValue} from '../../SimpleRangeValue' +import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' + +/** + * Computes the inclusive percentile using linear interpolation: rank = k * (n - 1). + * Assumes sortedVals is non-empty and k is in [0, 1]. + * + * @param sortedVals - pre-sorted array of numeric values (ascending) + * @param k - percentile fraction in [0, 1] + * @returns interpolated percentile value + */ +function percentileInclusive(sortedVals: number[], k: number): number { + const n = sortedVals.length + const rank = k * (n - 1) + const lowerIndex = Math.floor(rank) + const fraction = rank - lowerIndex + if (lowerIndex + 1 < n) { + return sortedVals[lowerIndex] + fraction * (sortedVals[lowerIndex + 1] - sortedVals[lowerIndex]) + } + return sortedVals[lowerIndex] +} + +/** + * Computes the exclusive percentile using linear interpolation: rank = k * (n + 1). + * Assumes sortedVals is non-empty and k is in (0, 1). + * Returns CellError if the resulting rank falls outside [1, n]. + * + * @param sortedVals - pre-sorted array of numeric values (ascending) + * @param k - percentile fraction in (0, 1) + * @returns interpolated percentile value, or CellError if rank is out of bounds + */ +function percentileExclusive(sortedVals: number[], k: number): number | CellError { + const n = sortedVals.length + const rank = k * (n + 1) + // Exclusive method requires rank in [1, n]; values outside mean k is too extreme for this dataset size + if (rank < 1) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueSmall) + } + if (rank > n) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) + } + const lowerIndex = Math.floor(rank) + const fraction = rank - lowerIndex + if (lowerIndex < n) { + return sortedVals[lowerIndex - 1] + fraction * (sortedVals[lowerIndex] - sortedVals[lowerIndex - 1]) + } + return sortedVals[lowerIndex - 1] +} + +/** + * Interpreter plugin for percentile and quartile statistical functions. + * + * Implements inclusive (INC) and exclusive (EXC) interpolation variants. + * QUARTILE functions delegate to PERCENTILE by converting quart index to + * a percentile fraction (quart / 4) after truncating to integer. + */ +export class PercentilePlugin extends FunctionPlugin implements FunctionPluginTypecheck { + + public static implementedFunctions: ImplementedFunctions = { + 'PERCENTILE.INC': { + method: 'percentile', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER, minValue: 0, maxValue: 1}, + ], + }, + 'PERCENTILE.EXC': { + method: 'percentileExc', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER, greaterThan: 0, lessThan: 1}, + ], + }, + 'QUARTILE.INC': { + method: 'quartile', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER}, + ], + }, + 'QUARTILE.EXC': { + method: 'quartileExc', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER}, + ], + }, + } + + public static aliases = { + PERCENTILE: 'PERCENTILE.INC', + QUARTILE: 'QUARTILE.INC', + } + + /** + * Corresponds to PERCENTILE(array, k) and PERCENTILE.INC(array, k). + * + * Returns the k-th percentile of values in a range using inclusive interpolation. + * + * @param ast - procedure AST node + * @param state - interpreter state + * @returns interpolated percentile value, or CellError on invalid input + */ + public percentile(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('PERCENTILE.INC'), + (range: SimpleRangeValue, k: number) => { + const vals = this.getSortedValues(range) + if (vals instanceof CellError) { + return vals + } + return percentileInclusive(vals, k) + } + ) + } + + /** + * Corresponds to PERCENTILE.EXC(array, k). + * + * Returns the k-th percentile of values in a range using exclusive interpolation. + * + * @param ast - procedure AST node + * @param state - interpreter state + * @returns interpolated percentile value, or CellError on invalid input + */ + public percentileExc(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('PERCENTILE.EXC'), + (range: SimpleRangeValue, k: number) => { + const vals = this.getSortedValues(range) + if (vals instanceof CellError) { + return vals + } + return percentileExclusive(vals, k) + } + ) + } + + /** + * Corresponds to QUARTILE(array, quart) and QUARTILE.INC(array, quart). + * + * Returns the quartile of a data set using inclusive interpolation. + * quart is truncated to an integer and validated in [0, 4]. + * + * @param ast - procedure AST node + * @param state - interpreter state + * @returns quartile value, or CellError on invalid input + */ + public quartile(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('QUARTILE.INC'), + (range: SimpleRangeValue, quart: number) => { + quart = Math.trunc(quart) + if (quart < 0) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueSmall) + } + if (quart > 4) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) + } + const vals = this.getSortedValues(range) + if (vals instanceof CellError) { + return vals + } + // Convert quartile index to percentile fraction: 0→0%, 1→25%, 2→50%, 3→75%, 4→100% + return percentileInclusive(vals, quart / 4) + } + ) + } + + /** + * Corresponds to QUARTILE.EXC(array, quart). + * + * Returns the quartile of a data set using exclusive interpolation. + * quart is truncated to an integer and validated in [1, 3]. + * + * @param ast - procedure AST node + * @param state - interpreter state + * @returns quartile value, or CellError on invalid input + */ + public quartileExc(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('QUARTILE.EXC'), + (range: SimpleRangeValue, quart: number) => { + quart = Math.trunc(quart) + if (quart < 1) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueSmall) + } + if (quart > 3) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) + } + const vals = this.getSortedValues(range) + if (vals instanceof CellError) { + return vals + } + // Convert quartile index to percentile fraction: 1→25%, 2→50%, 3→75% + return percentileExclusive(vals, quart / 4) + } + ) + } + + /** + * Extracts numeric values from a range, filters non-numbers, and returns them sorted. + * Returns CellError if the range contains an error, or if no numeric values exist. + * + * @param range - input range from the spreadsheet + * @returns sorted numeric values (ascending), or CellError + */ + private getSortedValues(range: SimpleRangeValue): number[] | CellError { + const vals = this.arithmeticHelper.manyToExactNumbers(range.valuesFromTopLeftCorner()) + if (vals instanceof CellError) { + return vals + } + if (vals.length === 0) { + return new CellError(ErrorType.NUM, ErrorMessage.OneValue) + } + vals.sort((a, b) => a - b) + return vals + } +} diff --git a/src/interpreter/plugin/index.ts b/src/interpreter/plugin/index.ts index e2f970d7f..2b1191c63 100644 --- a/src/interpreter/plugin/index.ts +++ b/src/interpreter/plugin/index.ts @@ -28,6 +28,7 @@ export {MathConstantsPlugin} from './MathConstantsPlugin' export {MatrixPlugin} from './MatrixPlugin' export {MedianPlugin} from './MedianPlugin' export {ModuloPlugin} from './ModuloPlugin' +export {PercentilePlugin} from './PercentilePlugin' export {NumericAggregationPlugin} from './NumericAggregationPlugin' export {PowerPlugin} from './PowerPlugin' export {RadiansPlugin} from './RadiansPlugin' From 110a28a3305fd58f76036a7551a256c431f853e0 Mon Sep 17 00:00:00 2001 From: Mateusz Wojczal Date: Tue, 28 Apr 2026 14:42:20 +0200 Subject: [PATCH 13/20] Make VuePress base, dest and sitemap hostname configurable (#1663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Move hardcoded `base: '/'` and the implicit dist output path out of `docs/.vuepress/config.js` so the docs site can be deployed under a sub-path (e.g. `/docs/`) without editing the config file. - Add `docs/.vuepress/build.config.js` as a single place to set production values (`base: '/docs/'`, `dest: 'docs/.vuepress/dist/docs'`, sitemap `hostname`). - Resolution order for each setting: env var (`DOCS_BASE` / `DOCS_DEST` / `DOCS_HOSTNAME`) → `build.config.js` → existing built-in default. `base` is normalized to start and end with `/`. - No sitemap plugin change needed — `vuepress-plugin-sitemap` already prepends `base` to every URL and writes `sitemap.xml` into the configured `dest`. ## Test plan - [ ] `npm run docs:build` completes successfully. - [ ] Output is written to `docs/.vuepress/dist/docs/` (not `docs/.vuepress/dist/`). - [ ] `docs/.vuepress/dist/docs/index.html` references assets under `/docs/...`. - [ ] `docs/.vuepress/dist/docs/sitemap.xml` exists and every `` is `https://hyperformula.handsontable.com/docs/...`. - [ ] Overriding via env still works: `DOCS_BASE=/ DOCS_DEST=docs/.vuepress/dist npm run docs:build` reproduces the old layout. - [ ] `npm run docs:dev` still serves locally. --- > [!NOTE] > **Medium Risk** > Medium risk because it changes docs build/deploy configuration (base path, output directory, sitemap hostname) and upgrades the expected Node version to 18, which could affect CI/hosting builds if environments aren’t aligned. > > **Overview** > Makes the VuePress docs build configurable by introducing `docs/.vuepress/build.config.js` and allowing `DOCS_BASE`, `DOCS_DEST`, and `DOCS_HOSTNAME` to override `base`, build output `dest`, and sitemap `hostname` (with `base` normalized to include leading/trailing `/`). > > Updates deployment defaults to publish docs under `/docs/` and output to `docs/.vuepress/dist/docs`, and adds `netlify.toml` plus a `.nvmrc` bump to Node 18 to align the build environment. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 67f61484b75d87d1b45d51f88c8f388437dc108f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .nvmrc | 2 +- docs/.vuepress/build.config.js | 12 ++++++++++++ docs/.vuepress/config.js | 25 +++++++++++++++++++++++-- netlify.toml | 6 ++++++ 4 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 docs/.vuepress/build.config.js create mode 100644 netlify.toml diff --git a/.nvmrc b/.nvmrc index 6f7f377bf..3f430af82 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16 +v18 diff --git a/docs/.vuepress/build.config.js b/docs/.vuepress/build.config.js new file mode 100644 index 000000000..633ea41f3 --- /dev/null +++ b/docs/.vuepress/build.config.js @@ -0,0 +1,12 @@ +/** + * Docs build configuration. + * Override any of these via environment variables: + * DOCS_BASE — public base path (must start and end with `/`) + * DOCS_DEST — output directory (relative to repo root) + * DOCS_HOSTNAME — absolute origin used for the sitemap + */ +module.exports = { + base: '/docs/', + dest: 'docs/.vuepress/dist/docs', + hostname: 'https://hyperformula.handsontable.com', +}; diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 70456f0da..2726f697a 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -8,6 +8,26 @@ const includeCodeSnippet = require('./plugins/markdown-it-include-code-snippet') const searchPattern = new RegExp('^/api', 'i'); +// Build configuration (override via env vars or docs/.vuepress/build.config.js) +const buildConfigOverrides = (() => { + try { + return require('./build.config.js'); + } catch (e) { + return {}; + } +})(); + +const normalizeBase = (b) => { + if (!b) return '/'; + let v = b.startsWith('/') ? b : '/' + b; + if (!v.endsWith('/')) v += '/'; + return v; +}; + +const DOCS_BASE = normalizeBase(process.env.DOCS_BASE || buildConfigOverrides.base || '/'); +const DOCS_DEST = process.env.DOCS_DEST || buildConfigOverrides.dest || 'docs/.vuepress/dist'; +const DOCS_HOSTNAME = process.env.DOCS_HOSTNAME || buildConfigOverrides.hostname || 'https://hyperformula.handsontable.com'; + module.exports = { title: 'HyperFormula (v' + HyperFormula.version + ')', description: 'HyperFormula is an open-source, high-performance calculation engine for spreadsheets and web applications.', @@ -61,10 +81,11 @@ module.exports = { ['link', { rel: 'manifest', href: '/favicon/site.webmanifest' }], ['link', { rel: 'mask-icon', color: '#ffffff', href: '/favicon/safari-pinned-tab.svg' }], ], - base: '/', + base: DOCS_BASE, + dest: DOCS_DEST, plugins: [ ['sitemap', { - hostname: 'https://hyperformula.handsontable.com', + hostname: DOCS_HOSTNAME, exclude: ['/404.html'], changefreq: 'weekly' }], diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 000000000..239f5392e --- /dev/null +++ b/netlify.toml @@ -0,0 +1,6 @@ +[build] + command = "npm run docs:build" + publish = "docs/.vuepress/dist/docs" + +[build.environment] + NODE_VERSION = "18" From 8b525cb11c6382de93cc47fa0c4bf5c6a622a8d0 Mon Sep 17 00:00:00 2001 From: Mateusz Wojczal Date: Tue, 28 Apr 2026 14:53:15 +0200 Subject: [PATCH 14/20] Fix Netlify publish dir so /docs/ is reachable (#1664) ## Summary Follow-up to #1663. The Netlify deploy succeeded but `https://hyperformula-docs.netlify.app/docs/` returns 404. Root cause: with `base: '/docs/'`, VuePress emits assets and internal links under `/docs/...` and writes the build to `docs/.vuepress/dist/docs/`. Setting `publish = "docs/.vuepress/dist/docs"` made Netlify serve those files at `/`, so the page rendered but every internal `/docs/...` reference 404'd. The publish dir must be the **parent** of the base path so the on-disk `docs/` subdirectory becomes the URL `/docs/`. Change: `publish = "docs/.vuepress/dist"` in `netlify.toml`. ## Test plan - [ ] Netlify deploys successfully. - [ ] `https://hyperformula-docs.netlify.app/docs/` renders the docs home (no 404). - [ ] Sub-pages like `/docs/guide/demo.html` load with assets and CSS intact. - [ ] `/sitemap.xml` is reachable and entries point at `https://hyperformula.handsontable.com/docs/...`. --- > [!NOTE] > **Low Risk** > Low risk configuration-only change that affects where Netlify serves built docs from; primary risk is misconfiguration leading to broken/404 docs paths. > > **Overview** > Fixes the Netlify deployment config by changing the `publish` directory from `docs/.vuepress/dist/docs` to `docs/.vuepress/dist`, ensuring the built `docs/` subdirectory is served at `/docs/` and internal asset/link paths resolve correctly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit be5594b279e2edbc67776df9700ebc9e11712d8a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- netlify.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netlify.toml b/netlify.toml index 239f5392e..f473180db 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,6 @@ [build] command = "npm run docs:build" - publish = "docs/.vuepress/dist/docs" + publish = "docs/.vuepress/dist" [build.environment] NODE_VERSION = "18" From 362df837747fe67f1bc8f02b0cc2b803c9100b8f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 5 May 2026 15:01:52 +0700 Subject: [PATCH 15/20] HF-85: Implement all database functions (#1652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Context Implements all 12 Excel database functions (D-functions family). Originally scoped to DCOUNT only, expanded to the full family since all share the same infrastructure (field resolution, criteria parsing, row matching). ### How did you test your changes? - 185 unit tests in hyperformula-tests (handsontable/hyperformula-tests#9) - 167-case Excel validation workbook (all PASS in Excel Desktop) - 147-test runtime integration suite + 30 edge case tests (booleans, negatives, zeros, wildcards, large DB, comparison operators) - Verified all error types match Excel precisely (#VALUE!, #DIV/0!, #NUM!) ### Types of changes - [x] New feature or improvement (a non-breaking change that adds functionality) - [x] Additional language file, or a change to an existing language file (translations) - [x] Change to the documentation ### Related issues: 1. Fixes HF-85 ### Checklist: - [x] I have reviewed the guidelines about Contributing to HyperFormula and I confirm that my code follows the code style of this project. - [x] My change is compatible with Microsoft Excel. - [x] My change is compatible with Google Sheets. - [x] I described my changes in the CHANGELOG.md file. - [x] My changes require a documentation update. --- ## Summary - 12 database functions: DCOUNT, DCOUNTA, DSUM, DAVERAGE, DMAX, DMIN, DGET, DPRODUCT, DSTDEV, DSTDEVP, DVAR, DVARP - New `DatabasePlugin` (533 lines) with shared infrastructure - i18n translations for all 17 languages (proper Excel-localized names) - Documentation: `built-in-functions.md` (Database section), `known-limitations.md` (Nuances) ## Implementation - `withDatabaseArgs()` helper eliminates boilerplate across all 12 functions - `resolveFieldIndex()` — string (case-insensitive header match) or 1-based numeric index with `Math.trunc()` - `buildDatabaseCriteria()` — OR across rows, AND within row, reuses `CriterionBuilder` - `rowMatchesCriteria()` — `.some()` (OR) + `.every()` (AND) - `collectNumericValues()` — shared by DSTDEV/DSTDEVP/DVAR/DVARP ## Excel behavior edge cases | Function | Edge case | Behavior | |---|---|---| | DMAX, DMIN, DPRODUCT | No matches | Returns 0 | | DGET | 0 matches / 2+ matches | #VALUE! / #NUM! | | DAVERAGE | No numeric values | #DIV/0! | | DSTDEV, DVAR | ≤1 value | #DIV/0! (sample, n-1) | | DSTDEVP, DVARP | 1 value / 0 values | 0 / #DIV/0! (population, n) | ## Linked - Tests PR: handsontable/hyperformula-tests#9 - ClickUp: HF-85 --- > [!NOTE] > **Medium Risk** > Adds new interpreter functionality that affects formula evaluation semantics (criteria parsing, error handling, and aggregation behavior), though changes are largely additive and isolated to a new plugin plus docs/i18n updates. > > **Overview** > Adds a new `DatabasePlugin` implementing the 12 Excel database functions (`DCOUNT`, `DCOUNTA`, `DSUM`, `DAVERAGE`, `DMAX`, `DMIN`, `DGET`, `DPRODUCT`, `DSTDEV`, `DSTDEVP`, `DVAR`, `DVARP`), including shared logic for field resolution, criteria parsing, row matching, and Excel-like error propagation. > > Updates the public surface by exporting the plugin, adding translations for these functions across language packs, and expanding docs/CHANGELOG to include a new **Database** functions category and function list. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 74c43972528c85c5594f5268af11734f8a08e6cd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + docs/guide/built-in-functions.md | 21 +- src/i18n/languages/csCZ.ts | 12 + src/i18n/languages/daDK.ts | 12 + src/i18n/languages/deDE.ts | 12 + src/i18n/languages/enGB.ts | 12 + src/i18n/languages/esES.ts | 12 + src/i18n/languages/fiFI.ts | 12 + src/i18n/languages/frFR.ts | 12 + src/i18n/languages/huHU.ts | 12 + src/i18n/languages/itIT.ts | 12 + src/i18n/languages/nbNO.ts | 12 + src/i18n/languages/nlNL.ts | 12 + src/i18n/languages/plPL.ts | 12 + src/i18n/languages/ptPT.ts | 12 + src/i18n/languages/ruRU.ts | 12 + src/i18n/languages/svSE.ts | 12 + src/i18n/languages/trTR.ts | 12 + src/interpreter/plugin/DatabasePlugin.ts | 595 +++++++++++++++++++++++ src/interpreter/plugin/index.ts | 1 + 20 files changed, 808 insertions(+), 2 deletions(-) create mode 100644 src/interpreter/plugin/DatabasePlugin.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21858277a..f118c1d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Added 12 database functions: DCOUNT, DSUM, DAVERAGE, DMAX, DMIN, DGET, DPRODUCT, DCOUNTA, DSTDEV, DSTDEVP, DVAR, DVARP. [#1652](https://github.com/handsontable/hyperformula/pull/1652) - Added new functions: PERCENTILE, PERCENTILE.INC, PERCENTILE.EXC, QUARTILE, QUARTILE.INC, QUARTILE.EXC. [#1650](https://github.com/handsontable/hyperformula/pull/1650) - 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) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index e1ad66821..b1c998e60 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -28,6 +28,7 @@ The latest version of HyperFormula has an extensive collection of **{{ $page.functionsCount }}** functions grouped into categories: - [Array manipulation](#array-manipulation) +- [Database](#database) - [Date and time](#date-and-time) - [Engineering](#engineering) - [Information](#information) @@ -40,8 +41,7 @@ The latest version of HyperFormula has an extensive collection of - [Statistical](#statistical) - [Text](#text) -_Some categories such as compatibility, cube, and database are yet to be -supported._ +_Some categories such as compatibility and cube are yet to be supported._ ::: tip You can modify the built-in functions or create your own, by adding a [custom function](custom-functions). @@ -91,6 +91,23 @@ Total number of functions: **{{ $page.functionsCount }}** | YEAR | Returns the year as a number according to the internal calculation rules. | YEAR(Number) | | YEARFRAC | Computes the difference between two date values, in fraction of years. | YEARFRAC(Date2, Date1[, Format]) | +### Database + +| Function ID | Description | Syntax | +|:------------|:----------------------------------------------------------------------------------------------------------------|:----------------------------------| +| DAVERAGE | Returns the average of all values in a database field that match the given criteria. | DAVERAGE(Database, Field, Criteria) | +| DCOUNT | Counts the cells containing numbers in a database field that match the given criteria. | DCOUNT(Database, Field, Criteria) | +| DCOUNTA | Counts the non-empty cells in a database field that match the given criteria. | DCOUNTA(Database, Field, Criteria) | +| DGET | Returns the single value from a database field that matches the given criteria. Returns #VALUE! if no records match, and #NUM! if more than one record matches. | DGET(Database, Field, Criteria) | +| DMAX | Returns the maximum value in a database field that matches the given criteria. | DMAX(Database, Field, Criteria) | +| DMIN | Returns the minimum value in a database field that matches the given criteria. | DMIN(Database, Field, Criteria) | +| DPRODUCT | Returns the product of all values in a database field that match the given criteria. | DPRODUCT(Database, Field, Criteria) | +| DSTDEV | Returns the sample standard deviation of all values in a database field that match the given criteria. | DSTDEV(Database, Field, Criteria) | +| DSTDEVP | Returns the population standard deviation of all values in a database field that match the given criteria. | DSTDEVP(Database, Field, Criteria) | +| DSUM | Returns the sum of all values in a database field that match the given criteria. | DSUM(Database, Field, Criteria) | +| DVAR | Returns the sample variance of all values in a database field that match the given criteria. | DVAR(Database, Field, Criteria) | +| DVARP | Returns the population variance of all values in a database field that match the given criteria. | DVARP(Database, Field, Criteria) | + ### Engineering | Function ID | Description | Syntax | diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 463bb1d2a..71ce56b14 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'ROK360', DAYS: 'DAYS', DB: 'ODPIS.ZRYCH', + DAVERAGE: 'DPRŮMĚR', + DCOUNT: 'DPOČET', + DCOUNTA: 'DPOČET2', + DGET: 'DZÍSKAT', + DMAX: 'DMAX', + DMIN: 'DMIN', + DPRODUCT: 'DSOUČIN', + DSTDEV: 'DSMODCH.VÝBĚR', + DSTDEVP: 'DSMODCH', + DSUM: 'DSUMA', + DVAR: 'DVAR.VÝBĚR', + DVARP: 'DVAR', DDB: 'ODPIS.ZRYCH2', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 6525c442a..ea5a1e4ce 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGE360', DAYS: 'DAGE', DB: 'DB', + DAVERAGE: 'DMIDDEL', + DCOUNT: 'DTÆL', + DCOUNTA: 'DTÆLV', + DGET: 'DHENT', + DMAX: 'DMAKS', + DMIN: 'DMIN', + DPRODUCT: 'DPRODUKT', + DSTDEV: 'DSTDAFV', + DSTDEVP: 'DSTDAFVP', + DSUM: 'DSUM', + DVAR: 'DVARIANS', + DVARP: 'DVARIANSP', DDB: 'DSA', DEC2BIN: 'DEC.TIL.BIN', DEC2HEX: 'DEC.TIL.HEX', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index d99d6cc1c..24a8ec03b 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'TAGE360', DAYS: 'TAGE', DB: 'GDA2', + DAVERAGE: 'DBMITTELWERT', + DCOUNT: 'DBANZAHL', + DCOUNTA: 'DBANZAHL2', + DGET: 'DBAUSZUG', + DMAX: 'DBMAX', + DMIN: 'DBMIN', + DPRODUCT: 'DBPRODUKT', + DSTDEV: 'DBSTDABW', + DSTDEVP: 'DBSTDABWN', + DSUM: 'DBSUMME', + DVAR: 'DBVARIANZ', + DVARP: 'DBVARIANZEN', DDB: 'GDA', DEC2BIN: 'DEZINBIN', DEC2HEX: 'DEZINHEX', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 840e14a90..e878f897a 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -76,6 +76,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAYS360', DAYS: 'DAYS', DB: 'DB', + DAVERAGE: 'DAVERAGE', + DCOUNT: 'DCOUNT', + DCOUNTA: 'DCOUNTA', + DGET: 'DGET', + DMAX: 'DMAX', + DMIN: 'DMIN', + DPRODUCT: 'DPRODUCT', + DSTDEV: 'DSTDEV', + DSTDEVP: 'DSTDEVP', + DSUM: 'DSUM', + DVAR: 'DVAR', + DVARP: 'DVARP', DDB: 'DDB', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 1d8278ca5..d36d14fd0 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -75,6 +75,18 @@ export const dictionary: RawTranslationPackage = { DAYS360: 'DIAS360', DAYS: 'DÍAS', DB: 'DB', + DAVERAGE: 'BDPROMEDIO', + DCOUNT: 'BDCONTAR', + DCOUNTA: 'BDCONTARA', + DGET: 'BDEXTRAER', + DMAX: 'BDMAX', + DMIN: 'BDMIN', + DPRODUCT: 'BDPRODUCTO', + DSTDEV: 'BDDESVEST', + DSTDEVP: 'BDDESVESTP', + DSUM: 'BDSUMA', + DVAR: 'BDVAR', + DVARP: 'BDVARP', DDB: 'DDB', DEC2BIN: 'DEC.A.BIN', DEC2HEX: 'DEC.A.HEX', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index cd11bc7ec..5735278d8 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'PÄIVÄT360', DAYS: 'PV', DB: 'DB', + DAVERAGE: 'TKESKIARVO', + DCOUNT: 'TLASKE', + DCOUNTA: 'TLASKE.A', + DGET: 'TNOUDA', + DMAX: 'TMAKS', + DMIN: 'TMIN', + DPRODUCT: 'TTULO', + DSTDEV: 'TKESKIHAJONTA', + DSTDEVP: 'TKESKIHAJONTAP', + DSUM: 'TSUMMA', + DVAR: 'TVARIANSSI', + DVARP: 'TVARIANSSIP', DDB: 'DDB', DEC2BIN: 'DESBIN', DEC2HEX: 'DESHEKSA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index ae1dd4389..044c70715 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'JOURS360', DAYS: 'JOURS', DB: 'DB', + DAVERAGE: 'BDMOYENNE', + DCOUNT: 'BDNB', + DCOUNTA: 'BDNBVAL', + DGET: 'BDLIRE', + DMAX: 'BDMAX', + DMIN: 'BDMIN', + DPRODUCT: 'BDPRODUIT', + DSTDEV: 'BDECARTYPE', + DSTDEVP: 'BDECARTYPEP', + DSUM: 'BDSOMME', + DVAR: 'BDVAR', + DVARP: 'BDVARP', DDB: 'DDB', DEC2BIN: 'DECBIN', DEC2HEX: 'DECHEX', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 223f12811..3a5119222 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAYS360', DAYS: 'NAPOK', DB: 'DB', + DAVERAGE: 'AB.ÁTLAG', + DCOUNT: 'AB.DARAB', + DCOUNTA: 'AB.DARAB2', + DGET: 'AB.MEZŐ', + DMAX: 'AB.MAX', + DMIN: 'AB.MIN', + DPRODUCT: 'AB.SZORZAT', + DSTDEV: 'AB.SZÓRÁS', + DSTDEVP: 'AB.SZÓRÁS2', + DSUM: 'AB.SZUM', + DVAR: 'AB.VAR', + DVARP: 'AB.VAR2', DDB: 'KCSA', DEC2BIN: 'DEC.BIN', DEC2HEX: 'DEC.HEX', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 1ebbaa255..2716d3471 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'GIORNO360', DAYS: 'GIORNI', DB: 'AMMORT.FISSO', + DAVERAGE: 'DB.MEDIA', + DCOUNT: 'DB.CONTA.NUMERI', + DCOUNTA: 'DB.CONTA.VALORI', + DGET: 'DB.VALORI', + DMAX: 'DB.MAX', + DMIN: 'DB.MIN', + DPRODUCT: 'DB.PRODOTTO', + DSTDEV: 'DB.DEV.ST', + DSTDEVP: 'DB.DEV.ST.POP', + DSUM: 'DB.SOMMA', + DVAR: 'DB.VAR', + DVARP: 'DB.VAR.POP', DDB: 'AMMORT', DEC2BIN: 'DECIMALE.BINARIO', DEC2HEX: 'DECIMALE.HEX', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 9ec608493..80d9948f6 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGER360', DAYS: 'DAGER', DB: 'DAVSKR', + DAVERAGE: 'DGJENNOMSNITT', + DCOUNT: 'DANTALL', + DCOUNTA: 'DANTALLA', + DGET: 'DHENT', + DMAX: 'DMAKS', + DMIN: 'DMIN', + DPRODUCT: 'DPRODUKT', + DSTDEV: 'DSTDAV', + DSTDEVP: 'DSTDAVP', + DSUM: 'DSUMMER', + DVAR: 'DVARIANS', + DVARP: 'DVARIANSP', DDB: 'DEGRAVS', DEC2BIN: 'DESTILBIN', DEC2HEX: 'DESTILHEKS', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 201318236..57d6fddb9 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGEN360', DAYS: 'DAGEN', DB: 'DB', + DAVERAGE: 'DBGEMIDDELDE', + DCOUNT: 'DBAANTAL', + DCOUNTA: 'DBAANTALC', + DGET: 'DBLEZEN', + DMAX: 'DBMAX', + DMIN: 'DBMIN', + DPRODUCT: 'DBPRODUKT', + DSTDEV: 'DBSTDAFWIJKING', + DSTDEVP: 'DBSTDAFWIJKINGP', + DSUM: 'DBSOM', + DVAR: 'DBVAR', + DVARP: 'DBVARP', DDB: 'DDB', DEC2BIN: 'DEC.N.BIN', DEC2HEX: 'DEC.N.HEX', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index acce07114..251a35bba 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DNI.360', DAYS: 'DNI', DB: 'DB', + DAVERAGE: 'BD.ŚREDNIA', + DCOUNT: 'BD.ILE.REKORDÓW', + DCOUNTA: 'BD.ILE.REKORDÓW.A', + DGET: 'BD.POLE', + DMAX: 'BD.MAX', + DMIN: 'BD.MIN', + DPRODUCT: 'BD.ILOCZYN', + DSTDEV: 'BD.ODCH.STANDARD', + DSTDEVP: 'BD.ODCH.STANDARD.POPUL', + DSUM: 'BD.SUMA', + DVAR: 'BD.WARIANCJA', + DVARP: 'BD.WARIANCJA.POPUL', DDB: 'DDB', DEC2BIN: 'DZIES.NA.DWÓJK', DEC2HEX: 'DZIES.NA.SZESN', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index a161a5497..3195612f0 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DIAS360', DAYS: 'DIAS', DB: 'BD', + DAVERAGE: 'BDMÉDIA', + DCOUNT: 'BDCONTAR', + DCOUNTA: 'BDCONTAR.VAL', + DGET: 'BDOBTER', + DMAX: 'BDMÁX', + DMIN: 'BDMÍN', + DPRODUCT: 'BDMULTIPL', + DSTDEV: 'BDDESVPAD', + DSTDEVP: 'BDDESVPADP', + DSUM: 'BDSOMA', + DVAR: 'BDVAR', + DVARP: 'BDVARP', DDB: 'BDD', DEC2BIN: 'DECABIN', DEC2HEX: 'DECAHEX', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 8d07da729..cfbf51e59 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'ДНЕЙ360', DAYS: 'ДНИ', DB: 'ФУО', + DAVERAGE: 'БДСРЕДНЕЕ', + DCOUNT: 'БСЧЁТ', + DCOUNTA: 'БСЧЁТА', + DGET: 'БИЗВЛЕЧЬ', + DMAX: 'БДМАКС', + DMIN: 'БДМИН', + DPRODUCT: 'БДПРОИЗВЕД', + DSTDEV: 'БДСТАНДОТКЛ', + DSTDEVP: 'БДСТАНДОТКЛП', + DSUM: 'БДСУММ', + DVAR: 'БДДИСП', + DVARP: 'БДДИСПП', DDB: 'ДДОБ', DEC2BIN: 'ДЕС.В.ДВ', DEC2HEX: 'ДЕС.В.ШЕСТН', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index c92269c33..bce2c4cf8 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGAR360', DAYS: 'DAYS', DB: 'DB', + DAVERAGE: 'DMEDEL', + DCOUNT: 'DANTAL', + DCOUNTA: 'DANTALV', + DGET: 'DHÄMTA', + DMAX: 'DMAX', + DMIN: 'DMIN', + DPRODUCT: 'DPRODUKT', + DSTDEV: 'DSTDAV', + DSTDEVP: 'DSTDAVP', + DSUM: 'DSUMMA', + DVAR: 'DVARIANS', + DVARP: 'DVARIANSP', DDB: 'DEGAVSKR', DEC2BIN: 'DEC.TILL.BIN', DEC2HEX: 'DEC.TILL.HEX', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 417b96bb9..af2c25390 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -75,6 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'GÜN360', DAYS: 'GÜNSAY', DB: 'AZALANBAKİYE', + DAVERAGE: 'VSEÇORT', + DCOUNT: 'VSEÇSAY', + DCOUNTA: 'VSEÇSAYDOLU', + DGET: 'VAL', + DMAX: 'VSEÇMAK', + DMIN: 'VSEÇMİN', + DPRODUCT: 'VSEÇÇARP', + DSTDEV: 'VSEÇSTDSAPMA', + DSTDEVP: 'VSEÇSTDSAPMAS', + DSUM: 'VSEÇTOPLA', + DVAR: 'VSEÇVAR', + DVARP: 'VSEÇVARS', DDB: 'ÇİFTAZALANBAKİYE', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts new file mode 100644 index 000000000..1208a0f17 --- /dev/null +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -0,0 +1,595 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {CellError, ErrorType} from '../../Cell' +import {ErrorMessage} from '../../error-message' +import {ProcedureAst} from '../../parser' +import {InterpreterState} from '../InterpreterState' +import {EmptyValue, getRawValue, InternalScalarValue, InterpreterValue, isExtendedNumber, RawScalarValue} from '../InterpreterValue' +import {SimpleRangeValue} from '../../SimpleRangeValue' +import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' +import {CriterionLambda} from '../Criterion' + +/** + * Parsed criterion for a single cell in the criteria range. + * Maps a database column index to a matching lambda. + */ +interface DatabaseCriterionEntry { + /** 0-based column index within the database range. */ + columnIndex: number, + /** Lambda that tests whether a raw cell value satisfies the criterion. */ + lambda: CriterionLambda, +} + +/** + * A single criteria row is a list of AND-ed criterion entries. + * Multiple criteria rows are OR-ed together. + */ +type DatabaseCriteriaRow = DatabaseCriterionEntry[] + +/** Shared parameter signature for all database functions: (database RANGE, field SCALAR, criteria RANGE). */ +const databaseFunctionParameters = [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, +] + +/** + * Interpreter plugin implementing Excel database functions. + * + * Implements: DAVERAGE, DCOUNT, DCOUNTA, DGET, DMAX, DMIN, DPRODUCT, DSTDEV, DSTDEVP, DSUM, DVAR, DVARP. + */ +export class DatabasePlugin extends FunctionPlugin implements FunctionPluginTypecheck { + + public static implementedFunctions: ImplementedFunctions = { + 'DCOUNT': {method: 'dcount', parameters: databaseFunctionParameters}, + 'DCOUNTA': {method: 'dcounta', parameters: databaseFunctionParameters}, + 'DPRODUCT': {method: 'dproduct', parameters: databaseFunctionParameters}, + 'DSTDEV': {method: 'dstdev', parameters: databaseFunctionParameters}, + 'DSTDEVP': {method: 'dstdevp', parameters: databaseFunctionParameters}, + 'DSUM': {method: 'dsum', parameters: databaseFunctionParameters}, + 'DVAR': {method: 'dvar', parameters: databaseFunctionParameters}, + 'DVARP': {method: 'dvarp', parameters: databaseFunctionParameters}, + 'DAVERAGE': {method: 'daverage', parameters: databaseFunctionParameters}, + 'DGET': {method: 'dget', parameters: databaseFunctionParameters}, + 'DMAX': {method: 'dmax', parameters: databaseFunctionParameters}, + 'DMIN': {method: 'dmin', parameters: databaseFunctionParameters}, + } + + /** + * Resolves field index and criteria, then delegates to the callback with the parsed database arguments. + * Shared boilerplate for all 12 database functions. + */ + private withDatabaseArgs( + ast: ProcedureAst, + state: InterpreterState, + functionName: string, + callback: (dbData: InternalScalarValue[][], fieldIndex: number, criteriaRows: DatabaseCriteriaRow[]) => InternalScalarValue + ): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata(functionName), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + return callback(database.data, fieldIndex, criteriaRows) + }) + } + + /** + * Counts cells containing numbers in the specified field of a database range, + * for rows that match all criteria. + * + * DCOUNT(database, field, criteria) + */ + public dcount(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DCOUNT', (dbData, fieldIndex, criteriaRows) => { + let count = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } + if (isExtendedNumber(cellValue)) { + count++ + } + } + } + + return count + }) + } + + /** + * Counts all non-blank cells in the specified field of a database range, + * for rows that match all criteria. + * + * DCOUNTA(database, field, criteria) + */ + public dcounta(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DCOUNTA', (dbData, fieldIndex, criteriaRows) => { + let count = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } + if (cellValue !== EmptyValue && cellValue !== undefined && cellValue !== null) { + count++ + } + } + } + + return count + }) + } + + /** + * Returns the product of numeric values in the specified field of a database range, + * for rows that match all criteria. + * + * DPRODUCT(database, field, criteria) + */ + public dproduct(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DPRODUCT', (dbData, fieldIndex, criteriaRows) => { + let product = 1 + let hasNumeric = false + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } + if (isExtendedNumber(cellValue)) { + product *= getRawValue(cellValue) + hasNumeric = true + } + } + } + + return hasNumeric ? product : 0 + }) + } + + /** + * Returns the sum of numeric values in the specified field of a database range, + * for rows that match all criteria. + * + * DSUM(database, field, criteria) + */ + public dsum(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DSUM', (dbData, fieldIndex, criteriaRows) => { + let sum = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } + if (isExtendedNumber(cellValue)) { + sum += getRawValue(cellValue) + } + } + } + + return sum + }) + } + + /** + * Returns the average of numeric values in the specified field of a database range, + * for rows that match all criteria. + * Returns #DIV/0! when no numeric values are found. + * + * DAVERAGE(database, field, criteria) + */ + public daverage(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DAVERAGE', (dbData, fieldIndex, criteriaRows) => { + let sum = 0 + let count = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } + if (isExtendedNumber(cellValue)) { + sum += getRawValue(cellValue) + count++ + } + } + } + + if (count === 0) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + return sum / count + }) + } + + /** + * Returns a single value from the specified field of a database range, + * for the row that matches all criteria. + * Returns #VALUE! if no rows match, #NUM! if more than one row matches. + * + * DGET(database, field, criteria) + */ + public dget(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DGET', (dbData, fieldIndex, criteriaRows) => { + let matchedValue: InternalScalarValue | undefined + let matchCount = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + matchCount++ + if (matchCount > 1) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) + } + matchedValue = dbData[rowIdx][fieldIndex] + } + } + + if (matchCount === 0) { + return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } + + if (matchedValue instanceof CellError) { + return matchedValue + } + + return matchedValue === EmptyValue || matchedValue === undefined || matchedValue === null + ? 0 + : matchedValue + }) + } + + /** + * Returns the maximum numeric value in the specified field of a database range, + * for rows that match all criteria. + * Returns 0 when no numeric values are found (Excel behavior). + * + * DMAX(database, field, criteria) + */ + public dmax(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DMAX', (dbData, fieldIndex, criteriaRows) => { + let max = -Infinity + let hasNumeric = false + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } + if (isExtendedNumber(cellValue)) { + const numValue = getRawValue(cellValue) + if (numValue > max) { + max = numValue + } + hasNumeric = true + } + } + } + + return hasNumeric ? max : 0 + }) + } + + /** + * Returns the minimum numeric value in the specified field of a database range, + * for rows that match all criteria. + * Returns 0 when no numeric values are found (Excel behavior). + * + * DMIN(database, field, criteria) + */ + public dmin(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DMIN', (dbData, fieldIndex, criteriaRows) => { + let min = Infinity + let hasNumeric = false + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } + if (isExtendedNumber(cellValue)) { + const numValue = getRawValue(cellValue) + if (numValue < min) { + min = numValue + } + hasNumeric = true + } + } + } + + return hasNumeric ? min : 0 + }) + } + + /** + * Returns the sample standard deviation of numeric values in the specified field + * of a database range, for rows that match all criteria. + * Returns #DIV/0! when fewer than 2 numeric values are found. + * + * DSTDEV(database, field, criteria) + */ + public dstdev(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DSTDEV', (dbData, fieldIndex, criteriaRows) => { + const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) + if (values instanceof CellError) { + return values + } + + if (values.length <= 1) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + const mean = values.reduce((a, b) => a + b, 0) / values.length + const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) + return Math.sqrt(variance) + }) + } + + /** + * Returns the population standard deviation of numeric values in the specified field + * of a database range, for rows that match all criteria. + * Returns #DIV/0! when no numeric values are found. + * Returns 0 when exactly one numeric value is found. + * + * DSTDEVP(database, field, criteria) + */ + public dstdevp(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DSTDEVP', (dbData, fieldIndex, criteriaRows) => { + const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) + if (values instanceof CellError) { + return values + } + + if (values.length === 0) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + const mean = values.reduce((a, b) => a + b, 0) / values.length + const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length + return Math.sqrt(variance) + }) + } + + /** + * Returns the sample variance of numeric values in the specified field + * of a database range, for rows that match all criteria. + * Returns #DIV/0! when fewer than 2 numeric values are found. + * + * DVAR(database, field, criteria) + */ + public dvar(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DVAR', (dbData, fieldIndex, criteriaRows) => { + const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) + if (values instanceof CellError) { + return values + } + + if (values.length <= 1) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + const mean = values.reduce((a, b) => a + b, 0) / values.length + return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) + }) + } + + /** + * Returns the population variance of numeric values in the specified field + * of a database range, for rows that match all criteria. + * Returns #DIV/0! when no numeric values are found. + * Returns 0 when exactly one numeric value is found. + * + * DVARP(database, field, criteria) + */ + public dvarp(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DVARP', (dbData, fieldIndex, criteriaRows) => { + const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) + if (values instanceof CellError) { + return values + } + + if (values.length === 0) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + const mean = values.reduce((a, b) => a + b, 0) / values.length + return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length + }) + } + + /** + * Collects numeric values from the specified field column of matching database rows. + * + * @param dbData - Full database data including header row. + * @param fieldIndex - 0-based column index of the target field. + * @param criteriaRows - Parsed criteria rows. + * @returns Array of numeric values from matching rows, or the first CellError + * encountered in a matching field cell (propagated per Excel semantics). + */ + private collectNumericValues( + dbData: InternalScalarValue[][], + fieldIndex: number, + criteriaRows: DatabaseCriteriaRow[] + ): number[] | CellError { + const values: number[] = [] + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } + if (isExtendedNumber(cellValue)) { + values.push(getRawValue(cellValue)) + } + } + } + + return values + } + + /** + * Resolves the field argument to a 0-based column index within the database range. + * + * @param database - The database range (first row = headers). + * @param field - A string (header name, case-insensitive) or number (1-based column index). + * Booleans are coerced to numbers (TRUE → 1, FALSE → 0) per Excel convention. + * @returns 0-based column index, or CellError if field is invalid. + */ + private resolveFieldIndex(database: SimpleRangeValue, field: RawScalarValue): number | CellError { + if (field instanceof CellError) { + return field + } + + const headers = database.data[0] + + if (typeof field === 'string') { + const lowerField = field.toLowerCase() + for (let i = 0; i < headers.length; i++) { + const header = headers[i] + if (typeof header === 'string' && header.toLowerCase() === lowerField) { + return i + } + } + return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } + + const numericField = typeof field === 'boolean' ? Number(field) : field + + if (isExtendedNumber(numericField)) { + const index = Math.trunc(getRawValue(numericField)) + if (!Number.isFinite(index) || index < 1 || index > headers.length) { + return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } + return index - 1 + } + + return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } + + /** + * Parses the criteria range into an array of criteria rows. + * Each row is a list of AND-ed conditions. Rows are OR-ed together. + * + * @param database - The database range (first row = headers). + * @param criteria - The criteria range (first row = header labels, subsequent rows = conditions). + * @returns Array of criteria rows, or CellError if a criterion cannot be parsed. + */ + private buildDatabaseCriteria(database: SimpleRangeValue, criteria: SimpleRangeValue): DatabaseCriteriaRow[] | CellError { + const dbHeaders = database.data[0] + const criteriaData = criteria.data + const criteriaHeaders = criteriaData[0] + + // Propagate errors from criteria header cells (Excel returns the header + // error when the D-function is evaluated, instead of silently skipping). + for (const criteriaHeader of criteriaHeaders) { + if (criteriaHeader instanceof CellError) { + return criteriaHeader + } + } + + // Map each criteria column to a database column index (or -1 if no match) + const criteriaColumnMapping: number[] = criteriaHeaders.map(criteriaHeader => { + if (typeof criteriaHeader !== 'string') { + return -1 + } + const lowerHeader = criteriaHeader.toLowerCase() + return dbHeaders.findIndex( + dbHeader => typeof dbHeader === 'string' && dbHeader.toLowerCase() === lowerHeader + ) + }) + + const rows: DatabaseCriteriaRow[] = [] + + for (let rowIdx = 1; rowIdx < criteriaData.length; rowIdx++) { + const row: DatabaseCriteriaRow = [] + + for (let colIdx = 0; colIdx < criteriaHeaders.length; colIdx++) { + const dbColIndex = criteriaColumnMapping[colIdx] + if (dbColIndex === -1) { + continue // Unknown criteria header — ignore + } + + const criterionValue = criteriaData[rowIdx]?.[colIdx] + + // Empty/blank criteria cell = match-all for that column — skip + if (criterionValue === EmptyValue || criterionValue === undefined || criterionValue === null) { + continue + } + + // Propagate errors from the criteria cells instead of masking them as BadCriterion + if (criterionValue instanceof CellError) { + return criterionValue + } + + const rawCriterionValue = isExtendedNumber(criterionValue) ? getRawValue(criterionValue) : criterionValue + + const criterionPackage = this.interpreter.criterionBuilder.fromCellValue( + rawCriterionValue as RawScalarValue, + this.arithmeticHelper + ) + + if (criterionPackage === undefined) { + return new CellError(ErrorType.VALUE, ErrorMessage.BadCriterion) + } + + row.push({ + columnIndex: dbColIndex, + lambda: criterionPackage.lambda, + }) + } + + rows.push(row) + } + + return rows + } + + /** + * Tests whether a database data row matches any of the criteria rows (OR logic). + * Within each criteria row, all conditions must match (AND logic). + * + * @param dataRow - A single row of data from the database (excluding the header row). + * @param criteriaRows - Parsed criteria rows. + * @returns true if the row qualifies, false otherwise. + */ + private rowMatchesCriteria(dataRow: InternalScalarValue[], criteriaRows: DatabaseCriteriaRow[]): boolean { + if (criteriaRows.length === 0) { + return false + } + + return criteriaRows.some(criteriaRow => { + if (criteriaRow.length === 0) { + return true // Empty criteria row = match all + } + + return criteriaRow.every(entry => { + const cellValue = dataRow[entry.columnIndex] + const rawValue = isExtendedNumber(cellValue) ? getRawValue(cellValue) : cellValue + return entry.lambda(rawValue) + }) + }) + } +} diff --git a/src/interpreter/plugin/index.ts b/src/interpreter/plugin/index.ts index 2b1191c63..e86ba4f2a 100644 --- a/src/interpreter/plugin/index.ts +++ b/src/interpreter/plugin/index.ts @@ -13,6 +13,7 @@ export {CharPlugin} from './CharPlugin' export {CodePlugin} from './CodePlugin' export {CountBlankPlugin} from './CountBlankPlugin' export {CountUniquePlugin} from './CountUniquePlugin' +export {DatabasePlugin} from './DatabasePlugin' export {DateTimePlugin} from './DateTimePlugin' export {DegreesPlugin} from './DegreesPlugin' export {DeltaPlugin} from './DeltaPlugin' From 456adddff16db48a629866fb69d72d6280a56e40 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 7 May 2026 19:17:41 +0700 Subject: [PATCH 16/20] HF-122: Framework integration guides for React, Angular, Vue, Svelte (#1653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Expand the four framework integration pages (React, Angular, Vue, Svelte) from one-line redirects into self-contained guides with code snippets extracted from the respective Stackblitz demos. Each guide's primary snippet is a simplified version of the demo's framework pattern — same lifecycle hooks, same service architecture, same reactivity approach — with simplified data (`buildFromArray` instead of Employee Table). ## Design rationale ### Snippets from demos, not invented patterns Per review feedback: every code snippet must match what's in the corresponding Stackblitz demo. This ensures the snippets are tested, idiomatic, and consistent with what users see when they click the demo link. Patterns not present in demos (e.g., Angular Signals, Svelte 5 runes) are deliberately excluded until validated by a framework expert. | Framework | Demo file | Primary pattern in guide | |---|---|---| | React | `react-demo/src/lib/employee/employee.provider.tsx` | `useRef` + `useEffect` init/cleanup + `useState` | | Angular | `angular-demo/src/app/employees/employees.service.ts` | `@Injectable` + `BehaviorSubject` + `async` pipe | | Vue | `vue-3-demo/src/lib/employees-data-provider.ts` | Class wrapper with private HF field + `ref` | | Svelte | `svelte-demo/src/routes/Hyperformula.svelte` | `buildFromArray` + `getCellValue` + `on:click` + `onDestroy` | ### Other decisions - **TypeScript** in all snippets (HF ships `.d.ts` typings) - **`licenseKey: 'gpl-v3'`** in every snippet (without it, engine throws license warning) - **SSR notes** for Next.js, Nuxt, SvelteKit (HF is SSR-safe — no browser-only API dependency — but instantiating it server-side is wasted work, so each framework's SSR section defers to client lifecycle) - **VuePress template fix** — Stackblitz links use `` Vue binding instead of `{{ }}` interpolation in markdown ## Test plan - [x] Render docs locally / verify all four integration pages — all 4 pages return HTTP 200 on the Netlify deploy preview for the latest commit (proxies `npm run docs:dev`) - [x] Click each Stackblitz demo link — all 5 URLs (4 frameworks + custom-functions) reachable, each `hyperformula-demos@3.2.x/-demo` subdir exists - [x] Verify primary snippets match demo patterns — React: `useRef`/`useEffect`/`useState`; Angular: `@Injectable`/`BehaviorSubject`/`async` pipe; Vue: class wrapper + `ref` (the `markRaw` pattern is documented in Troubleshooting, not the primary snippet); Svelte: `buildFromArray`/`getCellValue`/`on:click`/`onDestroy` - [x] Verify no untested patterns remain — no Signals, no `$state`/`$derived` runes, no NgZone; Pinia is mentioned only in a Vue Troubleshooting note that warns against putting the engine into Pinia state, not as a recommended pattern - [x] Confirm `licenseKey: 'gpl-v3'` present in every snippet — react/angular: 1× (main snippet); vue: 2× (main + Troubleshooting markRaw demo); svelte: 2× (basic + SSR variants) - [x] Confirm `destroy()` cleanup present in every applicable component snippet — react/angular: 1× (main snippet); vue: 1× (main snippet — Troubleshooting markRaw demo is illustrative, not a full component); svelte: 2× (basic + SSR variants) --- > [!NOTE] > **Low Risk** > Low risk documentation-only change that adds new framework-specific guidance and code snippets; no runtime/library behavior is modified. > > **Overview** > **Expands the framework integration docs** (Angular, React, Svelte, Vue) from brief install notes into self-contained guides with concrete TypeScript-centric examples for initializing HyperFormula, surfacing calculated values in each framework’s reactivity model, and cleaning up via the appropriate lifecycle hook. > > Adds SSR-specific notes for Angular Universal, Next.js, Nuxt, and SvelteKit, and standardizes demo links by switching Stackblitz URLs to Vue-bound `` so the cache-busting query param renders correctly in VuePress (also applied to `custom-functions`). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1ecce544aaa867348c16cb32445c8cf1b5e9c8d1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Kuba Sekowski Co-authored-by: Claude Sonnet 4.6 --- docs/guide/custom-functions.md | 2 +- docs/guide/integration-with-angular.md | 124 ++++++++++++++++++++++++- docs/guide/integration-with-react.md | 114 ++++++++++++++++++++++- docs/guide/integration-with-svelte.md | 123 +++++++++++++++++++++++- docs/guide/integration-with-vue.md | 96 +++++++++++++++++-- 5 files changed, 442 insertions(+), 17 deletions(-) diff --git a/docs/guide/custom-functions.md b/docs/guide/custom-functions.md index 68eedd9a4..e2253d894 100644 --- a/docs/guide/custom-functions.md +++ b/docs/guide/custom-functions.md @@ -358,7 +358,7 @@ it('returns a VALUE error if the range argument contains a string', () => { ## Working demo -Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/custom-functions?v=${$page.buildDateURIEncoded}). +Explore the full working example on Stackblitz. This demo contains the implementation of both the [`GREET`](#add-a-simple-custom-function) and diff --git a/docs/guide/integration-with-angular.md b/docs/guide/integration-with-angular.md index 8f78e2097..c7d587a4e 100644 --- a/docs/guide/integration-with-angular.md +++ b/docs/guide/integration-with-angular.md @@ -1,9 +1,127 @@ # Integration with Angular -Installing HyperFormula in an Angular application works the same as with vanilla JavaScript. +The HyperFormula API is identical in an Angular app and in plain JavaScript. This guide demonstrates how HyperFormula is integrated with an Angular app (typically as an injectable service), how it is cleaned up, and how you bridge its values into the change-detection cycle. -For more details, see the [client-side installation](client-side-installation.md) section. +Install with `npm install hyperformula`. For other options, see the [client-side installation](client-side-installation.md) section. + +## Basic usage + +Wrap the engine in an `@Injectable` service backed by a `BehaviorSubject`. Components subscribe to the observable with the `async` pipe, which handles subscription cleanup automatically. + +```typescript +// spreadsheet.service.ts +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { HyperFormula, type CellValue } from 'hyperformula'; + +@Injectable({ providedIn: 'root' }) +export class SpreadsheetService { + private readonly hf: HyperFormula; + + private readonly _values = new BehaviorSubject([]); + readonly values$ = this._values.asObservable(); + + constructor() { + this.hf = HyperFormula.buildFromArray( + [ + [1, 2, '=A1+B1'], + // your data rows go here + ], + { + licenseKey: 'gpl-v3', + // more configuration options go here + } + ); + this._values.next(this.hf.getSheetValues(0)); + } + + calculate() { + this._values.next(this.hf.getSheetValues(0)); + } + + reset() { + this._values.next([]); + } +} +``` + +Consume the service from a component and bind `values$ | async` in the template. Declare the component in your `AppModule` alongside `CommonModule`: + +```typescript +// spreadsheet.component.ts +import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { SpreadsheetService } from './spreadsheet.service'; +import { type CellValue } from 'hyperformula'; + +@Component({ + selector: 'app-spreadsheet', + templateUrl: './spreadsheet.component.html', +}) +export class SpreadsheetComponent { + values$: Observable; + + constructor(private spreadsheetService: SpreadsheetService) { + this.values$ = this.spreadsheetService.values$; + } + + runCalculations() { + this.spreadsheetService.calculate(); + } + + reset() { + this.spreadsheetService.reset(); + } +} +``` + +```html + + + + + + + + +
{{ cell }}
+
+``` + +## Notes + +### Provider scope + +`providedIn: 'root'` makes the service an application-wide singleton — suitable when a single HyperFormula instance is shared across the app. For per-feature or per-component instances (for example, several independent reports on one screen), provide the service at the component level via `providers: [SpreadsheetService]`; the service is then created and destroyed alongside the component. + +### Cleanup + +Root-scoped services live for the application's full lifetime — `ngOnDestroy` fires only at app shutdown. If you scope the service to a component (`providers: [SpreadsheetService]`), implement `OnDestroy` to release the engine: + +```typescript +import { Injectable, OnDestroy } from '@angular/core'; + +@Injectable() +export class SpreadsheetService implements OnDestroy { + // ... + + ngOnDestroy() { + this.hf.destroy(); + } +} +``` + +## Server-side rendering (Angular Universal) + +The service above is already SSR-safe — HyperFormula has no browser-only API dependency. To skip the (otherwise wasted) server-side instantiation in Angular Universal, gate the engine init with [`isPlatformBrowser`](https://angular.dev/api/common/isPlatformBrowser) from `@angular/common`. + +## Next steps + +- [Configuration options](configuration-options.md) — full list of `buildFromArray` / `buildEmpty` options +- [Basic operations](basic-operations.md) — CRUD on cells, rows, columns, sheets +- [Advanced usage](advanced-usage.md) — multi-sheet workbooks, named expressions +- [Custom functions](custom-functions.md) — register your own formulas ## Demo -Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/angular-demo?v=${$page.buildDateURIEncoded}). +For a more advanced example, check out the Angular demo on Stackblitz. diff --git a/docs/guide/integration-with-react.md b/docs/guide/integration-with-react.md index d4bc7fe75..7a4a68233 100644 --- a/docs/guide/integration-with-react.md +++ b/docs/guide/integration-with-react.md @@ -1,9 +1,117 @@ # Integration with React -Installing HyperFormula in a React application works the same as with vanilla JavaScript. +The HyperFormula API is identical in a React app and in plain JavaScript. This guide demonstrates how HyperFormula is integrated with the React component tree and how its lifecycle maps to React hooks. -For more details, see the [client-side installation](client-side-installation.md) section. +Install with `npm install hyperformula`. For other options, see the [client-side installation](client-side-installation.md) section. + +## Basic usage + +Hold the HyperFormula instance in a `useRef` so it survives re-renders. Initialize it inside `useEffect` and release it in the cleanup function. Use `useState` to toggle between raw formulas and computed values. + +```tsx +import { useEffect, useRef, useState } from 'react'; +import { HyperFormula } from 'hyperformula'; +import type { CellValue } from 'hyperformula'; + +export default function SpreadsheetComponent() { + const hfRef = useRef(null); + const [values, setValues] = useState([]); + + useEffect(() => { + const hf = HyperFormula.buildFromArray( + [ + [1, 2, '=A1+B1'], + // your data rows go here + ], + { + licenseKey: 'gpl-v3', + // more configuration options go here + } + ); + hfRef.current = hf; + + return () => { + hf.destroy(); + hfRef.current = null; + }; + }, []); + + function runCalculations() { + if (!hfRef.current) return; + setValues(hfRef.current.getSheetValues(0)); + } + + function reset() { + setValues([]); + } + + return ( + <> + + + {values.length > 0 && ( + + + {values.map((row, r) => ( + + {row.map((cell, c) => ( + + ))} + + ))} + +
{String(cell ?? '')}
+ )} + + ); +} +``` + +If you use JavaScript instead of TypeScript, drop the type annotations — the rest of the pattern is unchanged. + +## `React.StrictMode` double invocation + +In development, React runs effects twice (mount → unmount → mount) to surface cleanup bugs. The pattern above is correct for StrictMode because `destroy()` runs before the re-mount creates a new instance, so no work leaks between the two lifecycles. Do not switch to a module-scoped singleton as a workaround — it will break StrictMode semantics. + +## Server-side rendering (Next.js App Router) + +The component above is already SSR-safe — the engine is constructed in `useEffect`, which never runs on the server. If you still want to skip the initial bundle on the server (it is a few hundred kB), wrap it in a client-only dynamic import. + +In the App Router, `dynamic(..., { ssr: false })` is only allowed inside a client component. Put the dynamic call in a `'use client'` wrapper and import the wrapper from your server page: + +```tsx +// app/spreadsheet/SpreadsheetLazy.tsx +'use client'; +import dynamic from 'next/dynamic'; + +const SpreadsheetComponent = dynamic( + () => import('./SpreadsheetComponent'), + { ssr: false } +); + +export default function SpreadsheetLazy() { + return ; +} +``` + +```tsx +// app/spreadsheet/page.tsx ← server component, no 'use client' +import SpreadsheetLazy from './SpreadsheetLazy'; + +export default function Page() { + return ; +} +``` + +In the Pages Router, the same `dynamic(..., { ssr: false })` call works directly in the page file without a wrapper. + +## Next steps + +- [Configuration options](configuration-options.md) — full list of `buildFromArray` / `buildEmpty` options +- [Basic operations](basic-operations.md) — CRUD on cells, rows, columns, sheets +- [Advanced usage](advanced-usage.md) — multi-sheet workbooks, named expressions +- [Custom functions](custom-functions.md) — register your own formulas ## Demo -Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/react-demo?v=${$page.buildDateURIEncoded}). +For a more advanced example, check out the React demo on Stackblitz. diff --git a/docs/guide/integration-with-svelte.md b/docs/guide/integration-with-svelte.md index 8b3a5f4b6..2b43232fd 100644 --- a/docs/guide/integration-with-svelte.md +++ b/docs/guide/integration-with-svelte.md @@ -1,9 +1,126 @@ # Integration with Svelte -Installing HyperFormula in a Svelte application works the same as with vanilla JavaScript. +The HyperFormula API is identical in a Svelte app and in plain JavaScript. This guide demonstrates how HyperFormula integrates with the Svelte component's lifecycle and how you bridge its values into Svelte's reactivity. -For more details, see the [client-side installation](client-side-installation.md) section. +Install with `npm install hyperformula`. For other options, see the [client-side installation](client-side-installation.md) section. + +::: warning SvelteKit SSR +The primary snippet below assumes a browser environment. If you use SvelteKit with default SSR, skip to [Server-side rendering](#server-side-rendering-sveltekit) — `HyperFormula.buildFromArray` at ` + + + +{#if result !== null} +

Result: {result}

+{/if} + + + + {#each data as row, r} + + {#each row as cell, c} + + {/each} + + {/each} + +
+ {#if hf.doesCellHaveFormula({ sheet: sheetId, row: r, col: c })} + {hf.getCellFormula({ sheet: sheetId, row: r, col: c })} + {:else} + {hf.getCellValue({ sheet: sheetId, row: r, col: c })} + {/if} +
+``` + +## Server-side rendering (SvelteKit) + +In SvelteKit, top-level statements in ` + + + +{#if result !== null} +

Result: {result}

+{/if} +``` + + +## Next steps + +- [Configuration options](configuration-options.md) — full list of `buildFromArray` / `buildEmpty` options +- [Basic operations](basic-operations.md) — CRUD on cells, rows, columns, sheets +- [Advanced usage](advanced-usage.md) — multi-sheet workbooks, named expressions +- [Custom functions](custom-functions.md) — register your own formulas ## Demo -Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/svelte-demo?v=${$page.buildDateURIEncoded}). +For a more advanced example, check out the Svelte demo on Stackblitz. diff --git a/docs/guide/integration-with-vue.md b/docs/guide/integration-with-vue.md index eaa104e0e..65cacbda6 100644 --- a/docs/guide/integration-with-vue.md +++ b/docs/guide/integration-with-vue.md @@ -1,8 +1,83 @@ # Integration with Vue -Installing HyperFormula in a Vue application works the same as with vanilla JavaScript. +The HyperFormula API is identical in a Vue 3 app and in plain JavaScript. This guide demonstrates how HyperFormula integrates with the Vue reactivity system and how to surface its values in the template. -For more details, see the [client-side installation](client-side-installation.md) section. +Install with `npm install hyperformula`. For other options, see the [client-side installation](client-side-installation.md) section. + +## Basic usage + +Wrap the HyperFormula instance inside a plain class so it stays outside Vue's reactivity system (see [Troubleshooting](#vue-reactivity-issues) below for why this matters). Hold derived data in `ref` so the template updates when you reassign the ref's `.value`. + +```typescript +// spreadsheet-provider.ts +import { HyperFormula, type CellValue } from 'hyperformula'; + +export class SpreadsheetProvider { + private hf: HyperFormula; + + constructor(data: (string | number | null)[][]) { + this.hf = HyperFormula.buildFromArray(data, { + licenseKey: 'gpl-v3', + // more configuration options go here + }); + } + + getCalculatedValues(): CellValue[][] { + return this.hf.getSheetValues(0); + } + + getRawFormulas(): (string | number | null)[][] { + return this.hf.getSheetSerialized(0) as (string | number | null)[][]; + } + + destroy() { + this.hf.destroy(); + } +} +``` + +Use the class from a component with ` + + +``` + +The class keeps the HyperFormula instance as a private field, so Vue's reactivity Proxy never reaches it. This is the same pattern used in the [Vue 3 demo](#demo). + +## Server-side rendering (Nuxt) + +The class above is already SSR-safe — HyperFormula has no browser-only API dependency. To skip the (otherwise wasted) server-side instantiation in Nuxt, wrap the component with ``. ## Troubleshooting @@ -14,24 +89,31 @@ If you encounter an error like Uncaught TypeError: Cannot read properties of undefined (reading 'licenseKeyValidityState') ``` -it means that Vue's reactivity system tries to deeply observe the HyperFormula instance. To fix this, wrap your HyperFormula instance in Vue's [`markRaw`](https://vuejs.org/api/reactivity-advanced.html#markraw) function: +it means that Vue's reactivity system tried to deeply observe the HyperFormula instance. Vue wraps reactive objects in a `Proxy` that intercepts every property access; when that proxy reaches a non-trivial instance with its own internal state, identity checks and lazy-initialized maps break. The fix is to opt the instance out of reactivity with Vue's [`markRaw`](https://vuejs.org/api/reactivity-advanced.html#markraw): -```javascript +```typescript import { markRaw } from 'vue'; import { HyperFormula } from 'hyperformula'; const hfInstance = markRaw( HyperFormula.buildEmpty({ - licenseKey: 'internal-use-in-handsontable', + licenseKey: 'gpl-v3', }) ); ``` -This function prevents Vue from converting the HyperFormula instance into a reactive proxy, which can cause errors and performance issues. +`shallowRef` is not a substitute: it skips proxying only at the top level, so writing the instance into a nested reactive structure (Pinia state, `reactive({...})`) will still wrap it. Always pass the instance itself through `markRaw` before putting it anywhere Vue can reach. + +## Next steps + +- [Configuration options](configuration-options.md) — full list of `buildFromArray` / `buildEmpty` options +- [Basic operations](basic-operations.md) — CRUD on cells, rows, columns, sheets +- [Advanced usage](advanced-usage.md) — multi-sheet workbooks, named expressions +- [Custom functions](custom-functions.md) — register your own formulas ## Demo -Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/vue-3-demo?v=${$page.buildDateURIEncoded}). +For a more advanced example, check out the Vue 3 demo on Stackblitz. ::: tip This demo uses the [Vue 3](https://v3.vuejs.org/) framework. If you are looking for an example using Vue 2, check out the [code on GitHub](https://github.com/handsontable/hyperformula-demos/tree/2.5.x/vue-demo). From 24121f8daa405615dc2bc68e8d2b3f61be98d563 Mon Sep 17 00:00:00 2001 From: GreenFlux <24459976+GreenFlux@users.noreply.github.com> Date: Mon, 11 May 2026 07:26:31 -0400 Subject: [PATCH 17/20] docs: reframe AI SDK page as Vercel integration with prototype status (#1669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Reframe [docs/guide/ai-sdk.md](docs/guide/ai-sdk.md) around a HyperFormula + Vercel AI SDK integration with a single integrated `generateText` example as the only code block. - Mark the SDK as an unreleased prototype via a top-of-page warning callout, and add a prominent waitlist CTA with the existing HubSpot form. - Rename the sidebar entry in [docs/.vuepress/config.js](docs/.vuepress/config.js) to "Integration with Vercel AI SDK". Closes [HF-53](https://app.clickup.com/t/86c7upjar). Supersedes #1667. ## Test plan - [ ] `npm run docs:dev`, open `/guide/ai-sdk`, confirm sidebar reads "Integration with Vercel AI SDK" and the prototype callout sits above the fold. - [ ] Confirm the Vercel `generateText` snippet is the only code block and the page no longer carries Install / Setup / All options / TypeScript sections. - [ ] Click the waitlist link and external links (Vercel docs, GitHub, npm); click internal links to built-in / custom functions guides. --- > [!NOTE] > **Low Risk** > Low risk: documentation-only changes (sidebar label and guide content) with no runtime or API behavior impact. > > **Overview** > Reframes `docs/guide/ai-sdk.md` as **HyperFormula tools for the Vercel AI SDK**, adding a top-of-page *prototype/not-yet-released* warning, a single `generateText`-based example, updated use cases, and a waitlist CTA plus relevant links. > > Renames the guide’s sidebar entry in `docs/.vuepress/config.js` from “HyperFormula AI SDK” to **“Integration with Vercel AI SDK”**. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 3eb3e852deac008060102aba5ce5307401fe8e92. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). Co-authored-by: Joseph Petty --- docs/.vuepress/config.js | 2 +- docs/guide/ai-sdk.md | 73 ++++++++++++++++++++++++++-------------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 2726f697a..d185119bc 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -216,7 +216,7 @@ module.exports = { ['/guide/integration-with-vue', 'Integration with Vue'], ['/guide/integration-with-angular', 'Integration with Angular'], ['/guide/integration-with-svelte', 'Integration with Svelte'], - ['/guide/ai-sdk', 'HyperFormula AI SDK'], + ['/guide/ai-sdk', 'Integration with Vercel AI SDK'], ['/guide/integration-with-langchain', 'Integration with LangChain'], ['/guide/mcp-server', 'HyperFormula MCP Server'], ] diff --git a/docs/guide/ai-sdk.md b/docs/guide/ai-sdk.md index aeb93e6ca..d46a79877 100644 --- a/docs/guide/ai-sdk.md +++ b/docs/guide/ai-sdk.md @@ -1,51 +1,72 @@ -# HyperFormula AI SDK +# HyperFormula AI SDK for Vercel -Let LLMs safely read/write spreadsheets and compute formulas via a deterministic engine. +A [Vercel AI SDK](https://sdk.vercel.ai/docs) tool that gives your agents deterministic spreadsheet and formula computation — backed by HyperFormula's Excel-compatible engine. + +::: warning Prototype — not yet released +We have a working prototype. We'll make it available as soon as we open the beta. The API below is a preview and is likely to change before the first release. +::: ## What it does -- **Evaluate formulas on the fly** —call `calculateFormula()` to evaluate any Excel-compatible formula without placing it in a cell. -- **Read and write cells and ranges** —get or set individual cells and multi-cell ranges so an LLM can inspect, populate, or modify sheet data programmatically. -- **Trace dependencies** —call `getCellDependents()` and `getCellPrecedents()` to understand which cells feed into a formula and what downstream values would change. +- **Evaluate formulas on the fly** — your agent runs any Excel-compatible formula without placing it in a cell, so the model never has to do math itself. +- **Read and write cells and ranges** — the agent inspects, populates, or modifies sheet data through typed tool calls. +- **Trace dependencies** — precedents and dependents are surfaced so the agent can explain how a value is derived. + +## Example -## Quickstart +Using HyperFormula as a tool inside the Vercel AI SDK: ```js +import { generateText } from 'ai'; +import { openai } from '@ai-sdk/openai'; import HyperFormula from 'hyperformula'; import { createSpreadsheetTools } from 'hyperformula/ai'; -// 1. Create a HyperFormula instance with initial data +// Build a workbook your agent can reason about. const hf = HyperFormula.buildFromArray([ ['Revenue', 100], ['Cost', 60], ['Profit', '=B1-B2'], ]); -// 2. Create tools your LLM agent can call -const tools = createSpreadsheetTools(hf); +// Pass the spreadsheet tools straight into generateText. +const result = await generateText({ + model: openai('gpt-4o'), + tools: createSpreadsheetTools(hf), + prompt: 'What drives the profit number, and what happens if revenue doubles?', +}); +``` -// 3. Agent interaction examples -tools.evaluate({ formula: '=IRR({-1000,300,400,500,200})' }); -// → 0.1189 — deterministic, no LLM math +A single import, one extra line in `tools`, and the model can evaluate formulas, read ranges, and edit cells through the SDK — without inventing numbers. -tools.setCellContents({ sheet: 0, col: 1, row: 0, value: 200 }); -tools.getRange({ sheet: 0, startCol: 0, startRow: 0, endCol: 1, endRow: 2 }); -// → [['Revenue', 200], ['Cost', 60], ['Profit', 140]] +## Use cases -// Agent: "What drives the profit number?" -tools.getDependents({ sheet: 0, col: 1, row: 0 }); -// → [{ sheet: 0, col: 1, row: 2 }] — Revenue flows into Profit -``` +- **Explain a sheet** — an agent summarizes what a spreadsheet does, which cells are inputs, and how outputs are derived. +- **Generate a what-if scenario** — the model tweaks assumptions and reports how downstream results change. +- **Validate and clean data** — the agent scans ranges for errors, missing values, or inconsistencies and fixes them in place. +- **Create formulas from natural language** — the model translates a plain-English calculation into a verified Excel formula. -## Use cases +## Safety and guardrails + +HyperFormula runs locally in your Node.js or browser process — there's no remote service and no network or filesystem access through the tools. The agent's blast radius is limited to the in-memory workbook you hand it. + +Planned for the beta: -- **Explain a sheet** —ask an agent to summarize what a spreadsheet does, which cells are inputs, and how outputs are derived. -- **Generate a what-if scenario** —let the model tweak assumptions (price, volume, rate) and observe how results change in real time. -- **Validate and clean data** —have the agent scan ranges for errors, missing values, or inconsistencies and fix them with formulas or direct edits. -- **Create formulas from natural language** —describe a calculation in plain English and let the model write and verify the correct Excel formula. +- **Permissions per tool** — opt in to read-only, write, or formula-evaluation tools individually. +- **Range scoping** — restrict an agent to a named range, sheet, or address pattern. +- **Operation limits** — cap the number of cell writes or formula evaluations per turn. +- **Audit log** — every tool call returns a structured record of what changed. -## Beta access +## Join the waitlist ::: tip -[Sign up for beta access](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) +**When we're ready to launch the beta, you'll be the first to know.** [Drop your email in the waitlist →](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) ::: + +## Links + +- [Vercel AI SDK documentation](https://sdk.vercel.ai/docs) +- [HyperFormula on GitHub](https://github.com/handsontable/hyperformula) +- [HyperFormula on npm](https://www.npmjs.com/package/hyperformula) +- [Built-in functions](built-in-functions.md) +- [Custom functions](custom-functions.md) From 1b6767f14d96e359e65e509d9c006e5c910b7c6e Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Mon, 11 May 2026 15:09:56 +0200 Subject: [PATCH 18/20] Refine the AI toolkits landing pages (#1670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Refines the three AI-integration landing pages — Vercel AI SDK, LangChain/LangGraph, and MCP — so they share a single consistent voice, structure, and call to action, and so it is unmistakable that none of these integrations are installable yet. Resolves [HF-53](https://app.clickup.com/t/9015210959/HF-53). ## Why Before this change the three pages drifted in three different directions: - Only the AI SDK page warned readers that nothing was available yet — the LangChain and MCP pages read like a shipped product. - Page depth varied wildly (the LangChain and MCP pages were ~50 lines and missing both safety and links sections; the AI SDK page was much richer). - The LangChain page used a Python code example, even though HyperFormula is a TypeScript/JS-only library — misleading for readers. - "What it does" and "Use cases" were framed differently on each page (action-shaped vs. domain-shaped vs. host-shaped), hiding the fact that all three integrations expose the same capabilities to an agent. - The CTA varied between "Join the waitlist", "Sign up for beta access", and "Drop your email", with no consistent framing of the value of signing up. ## What changed ### Shared page template (applied to all three pages) Every page now follows the same section order: 1. Title + one-line tagline 2. **"Not available yet — coming soon"** warning admonition (identical wording on all three pages, with bolded "cannot be installed or used today") 3. `## What it does` — 4 capability bullets (identical wording on all three pages) 4. `## Example` — one short, idiomatic JS/TS example 5. `## Use cases` — 5 jobs-to-be-done bullets (identical on AI SDK and LangChain; MCP gets one extra leading bullet — see below) 7. `## Get early access` — identical CTA admonition framing sign-up as both *value to the user* (try it before the public release) and *value to us* (signals demand and shapes priorities) 8. `## Links` — uniform list ### "What it does" — now identical across all three pages - "Evaluate formulas deterministically" - "Read and write cells and ranges" - "Trace dependencies" - "400+ built-in functions out of the box" (newly surfaced — previously buried inside one LangChain use case despite being HF's biggest differentiator) ### "Use cases" — now nearly identical across all three pages Shared 5-bullet list (Spreadsheet Q&A, what-if and forecasting, validate and clean data, formulas from natural language, financial modeling and reporting). ### Per-page specifics - **`docs/guide/ai-sdk.md`** — kept as the strongest of the three; warning + CTA + section names harmonized; "Planned for the beta" → "Planned for the first release". - **`docs/guide/integration-with-langchain.md`** — full rewrite: removed the misleading Python example, added a LangGraph `createReactAgent` JS example mirroring the AI SDK one, added Safety/Links sections. - **`docs/guide/mcp-server.md`** — full rewrite: replaced the prose-style chat snippet with a concrete `npx -y @hyperformula/mcp` + client-config example, added Safety/Links sections, swapped "Beta access" CTA for the standardized "Get early access" admonition. ### Minor drive-by - `docs/guide/types-of-values.md` — dropped a stray `excel` language tag on a fenced code block that VitePress does not recognize. --- > [!NOTE] > **Low Risk** > Low risk documentation-only update that rewrites/aligns three integration guides and tweaks one code fence; no runtime or API behavior changes. > > **Overview** > Refactors the `ai-sdk`, `LangChain/LangGraph`, and `MCP server` guide pages to a consistent structure and voice, **explicitly stating the integrations are not yet available** and pointing readers to a unified early-access signup CTA. > > Updates each page’s capability and use-case messaging to match, replaces the LangChain example with an idiomatic JS/LangGraph snippet, and expands the MCP page with concrete `npx` run + client config examples plus a standardized `Links` section. > > Separately, fixes a docs formatting issue in `types-of-values.md` by removing an unsupported `excel` code-fence language tag. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a759bb4be8f879443f9781e0a9d5bb28d04182c8. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- docs/guide/ai-sdk.md | 35 ++++----- docs/guide/integration-with-langchain.md | 96 ++++++++++++++---------- docs/guide/mcp-server.md | 73 +++++++++++------- docs/guide/types-of-values.md | 2 +- 4 files changed, 119 insertions(+), 87 deletions(-) diff --git a/docs/guide/ai-sdk.md b/docs/guide/ai-sdk.md index d46a79877..c4c627fc4 100644 --- a/docs/guide/ai-sdk.md +++ b/docs/guide/ai-sdk.md @@ -2,15 +2,18 @@ A [Vercel AI SDK](https://sdk.vercel.ai/docs) tool that gives your agents deterministic spreadsheet and formula computation — backed by HyperFormula's Excel-compatible engine. -::: warning Prototype — not yet released -We have a working prototype. We'll make it available as soon as we open the beta. The API below is a preview and is likely to change before the first release. +::: warning Not available yet — coming soon +This integration is on our roadmap and **cannot be installed or used today**. The API shown below is a preview and may still change before the first release. + +If you'd like to try it, [join the early access list](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) — we'll ping you the moment the first beta is ready, and your sign-up directly tells us how strongly to prioritize this integration. ::: ## What it does -- **Evaluate formulas on the fly** — your agent runs any Excel-compatible formula without placing it in a cell, so the model never has to do math itself. +- **Evaluate formulas deterministically** — your agent runs any Excel-compatible formula through HyperFormula instead of asking the LLM to do math. Results are exact, reproducible, and auditable. - **Read and write cells and ranges** — the agent inspects, populates, or modifies sheet data through typed tool calls. -- **Trace dependencies** — precedents and dependents are surfaced so the agent can explain how a value is derived. +- **Trace dependencies** — precedents and dependents are surfaced so the agent can explain how every value was derived. +- **400+ built-in functions out of the box** — the agent has access to the full Excel-compatible function set (`SUM`, `VLOOKUP`, `IRR`, `INDEX/MATCH`, and the rest), no implementation work required. ## Example @@ -41,26 +44,18 @@ A single import, one extra line in `tools`, and the model can evaluate formulas, ## Use cases -- **Explain a sheet** — an agent summarizes what a spreadsheet does, which cells are inputs, and how outputs are derived. -- **Generate a what-if scenario** — the model tweaks assumptions and reports how downstream results change. +- **Spreadsheet Q&A** — ask the agent what a workbook does, which cells are inputs, and how each output is derived; get answers grounded in real formula evaluation. +- **What-if scenarios and forecasting** — the agent tweaks assumptions and reports how downstream results change, deterministically. - **Validate and clean data** — the agent scans ranges for errors, missing values, or inconsistencies and fixes them in place. -- **Create formulas from natural language** — the model translates a plain-English calculation into a verified Excel formula. - -## Safety and guardrails - -HyperFormula runs locally in your Node.js or browser process — there's no remote service and no network or filesystem access through the tools. The agent's blast radius is limited to the in-memory workbook you hand it. - -Planned for the beta: +- **Generate formulas from natural language** — the agent translates a plain-English calculation into a verified, working Excel formula. +- **Financial modeling and reporting** — NPV, IRR, amortization, KPI rollups, and other quantitative workflows where the answer must be exact and auditable. -- **Permissions per tool** — opt in to read-only, write, or formula-evaluation tools individually. -- **Range scoping** — restrict an agent to a named range, sheet, or address pattern. -- **Operation limits** — cap the number of cell writes or formula evaluations per turn. -- **Audit log** — every tool call returns a structured record of what changed. +## Get early access -## Join the waitlist +::: tip Be the first to try it +We're actively building this integration. Drop your email and we'll notify you the moment the first beta lands — so you can try it before the public release. -::: tip -**When we're ready to launch the beta, you'll be the first to know.** [Drop your email in the waitlist →](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) +[Join the early access list →](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) ::: ## Links diff --git a/docs/guide/integration-with-langchain.md b/docs/guide/integration-with-langchain.md index cbb695dcd..fad45db79 100644 --- a/docs/guide/integration-with-langchain.md +++ b/docs/guide/integration-with-langchain.md @@ -1,53 +1,71 @@ # Integration with LangChain/LangGraph -A LangChain/LangGraph tool that gives AI agents deterministic, Excel-compatible formula evaluation instead of relying on LLM-generated math. +A [LangChain.js](https://js.langchain.com/) / [LangGraph](https://langchain-ai.github.io/langgraphjs/) tool that gives your agents deterministic spreadsheet and formula computation — backed by HyperFormula's Excel-compatible engine. -## What it does - -**Without HyperFormula:** - -```python -result = llm.invoke( - "Calculate the IRR for these cash flows: [-1000, 300, 400, 500, 200]" -) -# "The IRR is approximately 12.4%" ← non-deterministic, unverifiable -``` - -**With HyperFormula tool:** - -```python -from langchain_core.tools import tool -from hyperformula import HyperFormula - -hf = HyperFormula.build_from_array([[-1000, 300, 400, 500, 200]]) +::: warning Not available yet — coming soon +This integration is on our roadmap and **cannot be installed or used today**. The API shown below is a preview and may still change before the first release. -@tool -def evaluate_formula(formula: str) -> str: - """Evaluate an Excel-compatible formula using HyperFormula.""" - return hf.calculate_formula(formula, sheet_id=0) +If you'd like to try it, [join the early access list](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) — we'll ping you the moment the first beta is ready, and your sign-up directly tells us how strongly to prioritize this integration. +::: -agent = create_react_agent(llm, [evaluate_formula]) +## What it does -# Agent calls: evaluate_formula("=IRR(A1:E1)") -# → 0.1189 ← deterministic, auditable +- **Evaluate formulas deterministically** — your agent runs any Excel-compatible formula through HyperFormula instead of asking the LLM to do math. Results are exact, reproducible, and auditable. +- **Read and write cells and ranges** — the agent inspects, populates, or modifies sheet data through typed tool calls. +- **Trace dependencies** — precedents and dependents are surfaced so the agent can explain how every value was derived. +- **400+ built-in functions out of the box** — the agent has access to the full Excel-compatible function set (`SUM`, `VLOOKUP`, `IRR`, `INDEX/MATCH`, and the rest), no implementation work required. + +## Example + +Wiring HyperFormula into a LangGraph ReAct agent: + +```js +import { ChatOpenAI } from '@langchain/openai'; +import { createReactAgent } from '@langchain/langgraph/prebuilt'; +import HyperFormula from 'hyperformula'; +import { createSpreadsheetTools } from 'hyperformula/langchain'; + +const hf = HyperFormula.buildFromArray([ + ['Revenue', 100], + ['Cost', 60], + ['Profit', '=B1-B2'], +]); + +const agent = createReactAgent({ + llm: new ChatOpenAI({ model: 'gpt-4o' }), + tools: createSpreadsheetTools(hf), +}); + +await agent.invoke({ + messages: [ + { role: 'user', content: 'What drives the profit number, and what happens if revenue doubles?' }, + ], +}); ``` -## How it works - -1. **Agent populates a HyperFormula sheet** —writes data and formulas (`=SUM`, `=IF`, `=VLOOKUP`, etc.) into cells. -2. **HyperFormula evaluates deterministically** —resolves the full dependency graph using 400+ built-in functions. No LLM in the loop for math. -3. **Agent continues with verified data** —computed values flow back into the chain for reasoning, reporting, or downstream actions. +A single import, one entry in `tools`, and the agent can evaluate formulas, read ranges, and edit cells through LangChain — without inventing numbers. ## Use cases -- Financial modeling (NPV, IRR, amortization) -- Data transformation and aggregation (SUMIF, VLOOKUP) -- Dynamic pricing with formula-defined logic -- What-if scenarios and forecasting -- Report generation with verified KPIs +- **Spreadsheet Q&A** — ask the agent what a workbook does, which cells are inputs, and how each output is derived; get answers grounded in real formula evaluation. +- **What-if scenarios and forecasting** — the agent tweaks assumptions and reports how downstream results change, deterministically. +- **Validate and clean data** — the agent scans ranges for errors, missing values, or inconsistencies and fixes them in place. +- **Generate formulas from natural language** — the agent translates a plain-English calculation into a verified, working Excel formula. +- **Financial modeling and reporting** — NPV, IRR, amortization, KPI rollups, and other quantitative workflows where the answer must be exact and auditable. -## Beta access +## Get early access -::: tip -[Sign up for beta access](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) +::: tip Be the first to try it +We're actively building this integration. Drop your email and we'll notify you the moment the first beta lands — so you can try it before the public release. + +[Join the early access list →](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) ::: + +## Links + +- [LangChain.js documentation](https://js.langchain.com/) +- [LangGraph documentation](https://langchain-ai.github.io/langgraphjs/) +- [HyperFormula on GitHub](https://github.com/handsontable/hyperformula) +- [HyperFormula on npm](https://www.npmjs.com/package/hyperformula) +- [Built-in functions](built-in-functions.md) +- [Custom functions](custom-functions.md) diff --git a/docs/guide/mcp-server.md b/docs/guide/mcp-server.md index 618323f22..7216032db 100644 --- a/docs/guide/mcp-server.md +++ b/docs/guide/mcp-server.md @@ -1,44 +1,63 @@ # HyperFormula MCP Server -An MCP (Model Context Protocol) server that exposes HyperFormula as a tool for any MCP-compatible AI client, giving LLMs deterministic spreadsheet computation. +An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes HyperFormula as a tool for any MCP-compatible AI client (Claude Desktop, Cursor, VS Code, and others) — giving LLMs deterministic spreadsheet and formula computation. -## What it does +::: warning Not available yet — coming soon +This integration is on our roadmap and **cannot be installed or used today**. The API shown below is a preview and may still change before the first release. -- **Evaluate formulas** —any MCP client can call HyperFormula to evaluate Excel-compatible formulas and get exact results. -- **Read and write cells** —get or set individual cell values and ranges through standard MCP tool calls. -- **Inspect dependencies** —trace which cells a formula depends on and understand the calculation graph. +If you'd like to try it, [join the early access list](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) — we'll ping you the moment the first beta is ready, and your sign-up directly tells us how strongly to prioritize this integration. +::: -**Without HyperFormula:** +## What it does -``` -User: What's the NPV at 8% for these cash flows? -Agent: "Approximately $142.50" ← non-deterministic, unverifiable -``` +- **Evaluate formulas deterministically** — your agent runs any Excel-compatible formula through HyperFormula instead of asking the LLM to do math. Results are exact, reproducible, and auditable. +- **Read and write cells and ranges** — the agent inspects, populates, or modifies sheet data through typed tool calls. +- **Trace dependencies** — precedents and dependents are surfaced so the agent can explain how every value was derived. +- **400+ built-in functions out of the box** — the agent has access to the full Excel-compatible function set (`SUM`, `VLOOKUP`, `IRR`, `INDEX/MATCH`, and the rest), no implementation work required. -**With HyperFormula MCP server:** +## Example -``` -User: What's the NPV at 8% for these cash flows? -Agent → tool call: evaluate("=NPV(0.08, B1:B5)") -Agent: "$138.43" ← deterministic, auditable +Run the server (no install needed once published): + +```bash +npx -y @hyperformula/mcp ``` -## How it works +Wire it into an MCP client by adding it to the client's config (for example, `claude_desktop_config.json` or `.cursor/mcp.json`): + +```json +{ + "mcpServers": { + "hyperformula": { + "command": "npx", + "args": ["-y", "@hyperformula/mcp"] + } + } +} +``` -1. **Start the MCP server** —runs HyperFormula as a local MCP server that any compatible client (Claude Desktop, Cursor, VS Code, etc.) can connect to. -2. **Client sends tool calls** —the AI client calls tools like `evaluate`, `getCellValue`, and `setCellContents` via the MCP protocol. -3. **HyperFormula evaluates deterministically** —resolves formulas using 400+ built-in functions with full dependency tracking. No LLM in the loop for math. -4. **Results flow back to the client** —computed values return through MCP, grounding the AI's response in verified numbers. +The client now sees tools like `evaluate`, `getCellValue`, and `setCellContents`, and the agent can call them as part of any conversation — without inventing numbers. ## Use cases -- Spreadsheet Q&A in Claude Desktop or other MCP clients -- Formula evaluation in IDE-based AI assistants -- Financial calculations in chat-based agent workflows -- Data validation and transformation via natural language +- **Spreadsheet Q&A** — ask the agent what a workbook does, which cells are inputs, and how each output is derived; get answers grounded in real formula evaluation. +- **What-if scenarios and forecasting** — the agent tweaks assumptions and reports how downstream results change, deterministically. +- **Validate and clean data** — the agent scans ranges for errors, missing values, or inconsistencies and fixes them in place. +- **Generate formulas from natural language** — the agent translates a plain-English calculation into a verified, working Excel formula. +- **Financial modeling and reporting** — NPV, IRR, amortization, KPI rollups, and other quantitative workflows where the answer must be exact and auditable. + +## Get early access -## Beta access +::: tip Be the first to try it +We're actively building this integration. Drop your email and we'll notify you the moment the first beta lands — so you can try it before the public release. -::: tip -[Sign up for beta access](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) +[Join the early access list →](https://2fmjvg.share-eu1.hsforms.com/2e6drCkuLTn-1RuiYB91eJA) ::: + +## Links + +- [Model Context Protocol specification](https://modelcontextprotocol.io/) +- [HyperFormula on GitHub](https://github.com/handsontable/hyperformula) +- [HyperFormula on npm](https://www.npmjs.com/package/hyperformula) +- [Built-in functions](built-in-functions.md) +- [Custom functions](custom-functions.md) diff --git a/docs/guide/types-of-values.md b/docs/guide/types-of-values.md index b0312e88f..67bca940f 100644 --- a/docs/guide/types-of-values.md +++ b/docs/guide/types-of-values.md @@ -103,7 +103,7 @@ operations such as calculating the number of days between two dates. When working with text values directly inside formulas, you must enclose them in double quotes (`"`). This is different from entering text into cells, where quotes are not required. E.g.: -```excel +``` =IF(B1="Active", "Status OK", "Check Status") ``` From bbacfc9f9ecc148a73809964357448c2b7fba9d0 Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Mon, 11 May 2026 15:33:10 +0200 Subject: [PATCH 19/20] 3.3.0 --- CHANGELOG.md | 2 + docs/guide/custom-functions.md | 2 +- docs/guide/integration-with-angular.md | 2 +- docs/guide/integration-with-react.md | 2 +- docs/guide/integration-with-svelte.md | 2 +- docs/guide/integration-with-vue.md | 2 +- docs/guide/release-notes.md | 19 + docs/index.md | 2 +- ht.config.js | 2 +- package-lock.json | 5508 +++++++++++++++++++++++- package.json | 2 +- 11 files changed, 5330 insertions(+), 215 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f118c1d79..6f5519980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [3.3.0] - 2026-05-18 + ### Added - Added 12 database functions: DCOUNT, DSUM, DAVERAGE, DMAX, DMIN, DGET, DPRODUCT, DCOUNTA, DSTDEV, DSTDEVP, DVAR, DVARP. [#1652](https://github.com/handsontable/hyperformula/pull/1652) diff --git a/docs/guide/custom-functions.md b/docs/guide/custom-functions.md index e2253d894..49794b1d3 100644 --- a/docs/guide/custom-functions.md +++ b/docs/guide/custom-functions.md @@ -358,7 +358,7 @@ it('returns a VALUE error if the range argument contains a string', () => { ## Working demo -Explore the full working example on Stackblitz. +Explore the full working example on Stackblitz. This demo contains the implementation of both the [`GREET`](#add-a-simple-custom-function) and diff --git a/docs/guide/integration-with-angular.md b/docs/guide/integration-with-angular.md index c7d587a4e..491908f84 100644 --- a/docs/guide/integration-with-angular.md +++ b/docs/guide/integration-with-angular.md @@ -124,4 +124,4 @@ The service above is already SSR-safe — HyperFormula has no browser-only API d ## Demo -For a more advanced example, check out the Angular demo on Stackblitz. +For a more advanced example, check out the Angular demo on Stackblitz. diff --git a/docs/guide/integration-with-react.md b/docs/guide/integration-with-react.md index 7a4a68233..bdc7cd567 100644 --- a/docs/guide/integration-with-react.md +++ b/docs/guide/integration-with-react.md @@ -114,4 +114,4 @@ In the Pages Router, the same `dynamic(..., { ssr: false })` call works directly ## Demo -For a more advanced example, check out the React demo on Stackblitz. +For a more advanced example, check out the React demo on Stackblitz. diff --git a/docs/guide/integration-with-svelte.md b/docs/guide/integration-with-svelte.md index 2b43232fd..007ddff3d 100644 --- a/docs/guide/integration-with-svelte.md +++ b/docs/guide/integration-with-svelte.md @@ -123,4 +123,4 @@ In SvelteKit, top-level statements in `