From 6de904b8876f920f287b63a95934c479acf78307 Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Fri, 20 Feb 2026 13:18:49 +0100 Subject: [PATCH 01/14] Deploying fix to the documentation (#1622) * Fix package-lock file * 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 +- package-lock.json | 224 +++++++++++++++++++++++++ 6 files changed, 231 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). ::: - - 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 b3028af867c20d7f8eff821169779db50650a944 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 10:37:45 +0000 Subject: [PATCH 02/14] Add built-in TEXTJOIN function to TextPlugin Implement Excel-compatible TEXTJOIN(delimiter, ignore_empty, text1, ...) as a native function in TextPlugin alongside CONCATENATE. The function joins text from multiple strings/ranges with a configurable delimiter and optionally skips empty strings. Returns #VALUE! when the result exceeds 32,767 characters (Excel cell content limit). - Add TEXTJOIN metadata and method to TextPlugin.ts - Add TextJoinResultTooLong error message to error-message.ts - Add function name translations to all 17 language files - Add 7 test cases covering ignore_empty, ranges, edge cases https://claude.ai/code/session_0138Q9YKLkHZ9geHUsEC6zFr --- 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 | 35 ++++++++++++ test/smoke.spec.ts | 84 ++++++++++++++++++++++++++++ 19 files changed, 136 insertions(+) diff --git a/src/error-message.ts b/src/error-message.ts index d9fded74c..dc8db42dc 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 TextJoinResultTooLong = 'TEXTJOIN result exceeds the maximum allowed length of 32,767 characters.' 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..b1740a695 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -156,6 +156,16 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec {argumentType: FunctionArgumentType.SCALAR} ] }, + 'TEXTJOIN': { + method: 'textjoin', + expandRanges: true, + repeatLastArgs: 1, + parameters: [ + {argumentType: FunctionArgumentType.STRING}, + {argumentType: FunctionArgumentType.BOOLEAN}, + {argumentType: FunctionArgumentType.STRING}, + ], + }, } /** @@ -424,6 +434,31 @@ 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. + * 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'), + (delimiter: string, ignoreEmpty: boolean, ...texts: string[]) => { + const parts = ignoreEmpty + ? texts.filter((t) => t !== '') + : texts + const result = parts.join(delimiter) + if (result.length > 32767) { + return new CellError(ErrorType.VALUE, ErrorMessage.TextJoinResultTooLong) + } + return result + } + ) + } + /** * Parses a string to a numeric value, handling whitespace trimming and empty string validation. * diff --git a/test/smoke.spec.ts b/test/smoke.spec.ts index 1e520c1b9..e0de3eb09 100644 --- a/test/smoke.spec.ts +++ b/test/smoke.spec.ts @@ -63,6 +63,90 @@ describe('HyperFormula', () => { hf.destroy() }) + it('should evaluate TEXTJOIN with ignore_empty=TRUE', () => { + const data = [ + ['Hello', 'World', '', '=TEXTJOIN(", ", TRUE(), A1:C1)'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue(adr('D1'))).toBe('Hello, World') + + hf.destroy() + }) + + it('should evaluate TEXTJOIN with ignore_empty=FALSE', () => { + const data = [ + ['Hello', 'World', '', '=TEXTJOIN(", ", FALSE(), A1:C1)'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue(adr('D1'))).toBe('Hello, World, ') + + hf.destroy() + }) + + it('should evaluate TEXTJOIN with individual cell references', () => { + const data = [ + ['a', 'b', 'c', '=TEXTJOIN("-", TRUE(), A1, B1, C1)'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue(adr('D1'))).toBe('a-b-c') + + hf.destroy() + }) + + it('should evaluate TEXTJOIN with single argument', () => { + const data = [ + ['only', '=TEXTJOIN(",", TRUE(), A1)'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue(adr('B1'))).toBe('only') + + hf.destroy() + }) + + it('should evaluate TEXTJOIN with empty delimiter', () => { + const data = [ + ['x', 'y', 'z', '=TEXTJOIN("", TRUE(), A1:C1)'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue(adr('D1'))).toBe('xyz') + + hf.destroy() + }) + + it('should evaluate TEXTJOIN with all-empty range and ignore_empty=TRUE', () => { + const data = [ + ['', '', '', '=TEXTJOIN(",", TRUE(), A1:C1)'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue(adr('D1'))).toBe('') + + hf.destroy() + }) + + it('should evaluate TEXTJOIN with all-empty range and ignore_empty=FALSE', () => { + const data = [ + ['', '', '', '=TEXTJOIN(",", FALSE(), A1:C1)'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue(adr('D1'))).toBe(',,') + + hf.destroy() + }) + it('should add and remove rows with formula updates', () => { const data = [ [1], From e916248e8ba7f26170651b729c9577d29b0333a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 15:42:31 +0000 Subject: [PATCH 03/14] Add array delimiter support to TEXTJOIN and comprehensive unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite TEXTJOIN to accept range/array as delimiter argument with cycling behavior (e.g., TEXTJOIN({"-","/"}, TRUE, "a","b","c") → "a-b/c") - Change parameter types from STRING to ANY to support ranges without expandRanges - Manually flatten text ranges and coerce values to strings - Add 26 dedicated unit tests in test/textjoin.spec.ts covering: - Basic functionality (literal strings, ranges, empty delimiter) - ignore_empty behavior (empty cells, ="" cells, all-empty ranges) - Array/range delimiter cycling - Type coercion (numbers, booleans) - Error propagation (#DIV/0!, #N/A) - Edge cases (32767 char limit, mixed scalar and range args) - Add array delimiter smoke test https://claude.ai/code/session_0138Q9YKLkHZ9geHUsEC6zFr --- src/interpreter/plugin/TextPlugin.ts | 58 +++++- test/smoke.spec.ts | 12 ++ test/textjoin.spec.ts | 269 +++++++++++++++++++++++++++ 3 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 test/textjoin.spec.ts diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index b1740a695..a6171c3dc 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' @@ -158,12 +159,11 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec }, 'TEXTJOIN': { method: 'textjoin', - expandRanges: true, repeatLastArgs: 1, parameters: [ - {argumentType: FunctionArgumentType.STRING}, + {argumentType: FunctionArgumentType.ANY}, {argumentType: FunctionArgumentType.BOOLEAN}, - {argumentType: FunctionArgumentType.STRING}, + {argumentType: FunctionArgumentType.ANY}, ], }, } @@ -438,6 +438,7 @@ 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). * @@ -446,11 +447,52 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec */ public textjoin(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('TEXTJOIN'), - (delimiter: string, ignoreEmpty: boolean, ...texts: string[]) => { - const parts = ignoreEmpty - ? texts.filter((t) => t !== '') - : texts - const result = parts.join(delimiter) + (delimiterArg: InternalScalarValue | SimpleRangeValue, + ignoreEmpty: boolean, + ...textArgs: (InternalScalarValue | SimpleRangeValue)[]) => { + + const delimiters: string[] = [] + const delimiterValues = delimiterArg instanceof SimpleRangeValue + ? delimiterArg.valuesFromTopLeftCorner() + : [delimiterArg] + for (const val of delimiterValues) { + if (val instanceof CellError) { + return val + } + const coerced = coerceScalarToString(val as InternalScalarValue) + if (coerced instanceof CellError) { + return coerced + } + delimiters.push(coerced) + } + + const texts: string[] = [] + for (const arg of textArgs) { + const values = arg instanceof SimpleRangeValue + ? arg.valuesFromTopLeftCorner() + : [arg] + for (const val of values) { + if (val instanceof CellError) { + return val + } + const coerced = coerceScalarToString(val as InternalScalarValue) + if (coerced instanceof CellError) { + return coerced + } + texts.push(coerced) + } + } + + const parts = ignoreEmpty ? texts.filter((t) => t !== '') : texts + + if (parts.length === 0) { + return '' + } + let result = parts[0] + for (let i = 1; i < parts.length; i++) { + result += delimiters[(i - 1) % delimiters.length] + parts[i] + } + if (result.length > 32767) { return new CellError(ErrorType.VALUE, ErrorMessage.TextJoinResultTooLong) } diff --git a/test/smoke.spec.ts b/test/smoke.spec.ts index e0de3eb09..efadb493c 100644 --- a/test/smoke.spec.ts +++ b/test/smoke.spec.ts @@ -147,6 +147,18 @@ describe('HyperFormula', () => { hf.destroy() }) + it('should evaluate TEXTJOIN with array delimiter (cycling)', () => { + const data = [ + ['-', '/', '=TEXTJOIN(A1:B1, TRUE(), "a", "b", "c", "d")'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue(adr('C1'))).toBe('a-b/c-d') + + hf.destroy() + }) + it('should add and remove rows with formula updates', () => { const data = [ [1], diff --git a/test/textjoin.spec.ts b/test/textjoin.spec.ts new file mode 100644 index 000000000..df5c8b714 --- /dev/null +++ b/test/textjoin.spec.ts @@ -0,0 +1,269 @@ +import {HyperFormula} from '../src' +import {ErrorType} from '../src/Cell' +import {DetailedCellError} from '../src/CellValue' +import {adr} from './testUtils' + +describe('TEXTJOIN', () => { + // Helper to build a single-sheet engine and return cell value + const evaluate = (data: any[][]) => { + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + return {hf, val: (ref: string) => hf.getCellValue(adr(ref))} + } + + afterEach(() => { + // HyperFormula instances are destroyed in each test + }) + + describe('basic functionality', () => { + it('should join literal strings with a scalar delimiter', () => { + const {hf, val} = evaluate([['=TEXTJOIN(", ", TRUE(), "Hello", "World")']]) + expect(val('A1')).toBe('Hello, World') + hf.destroy() + }) + + it('should join a range of cells', () => { + const {hf, val} = evaluate([['a', 'b', 'c', '=TEXTJOIN("-", TRUE(), A1:C1)']]) + expect(val('D1')).toBe('a-b-c') + hf.destroy() + }) + + it('should concatenate when delimiter is empty string', () => { + const {hf, val} = evaluate([['x', 'y', 'z', '=TEXTJOIN("", TRUE(), A1:C1)']]) + expect(val('D1')).toBe('xyz') + hf.destroy() + }) + + it('should handle multi-character delimiter', () => { + const {hf, val} = evaluate([['a', 'b', 'c', '=TEXTJOIN(", ", TRUE(), A1:C1)']]) + expect(val('D1')).toBe('a, b, c') + hf.destroy() + }) + + it('should return single text value without delimiter', () => { + const {hf, val} = evaluate([['=TEXTJOIN(",", TRUE(), "only")']]) + expect(val('A1')).toBe('only') + hf.destroy() + }) + + it('should join mixed scalar and range arguments', () => { + const {hf, val} = evaluate([ + ['a', 'b', 'c'], + ['=TEXTJOIN("-", TRUE(), "start", A1:C1, "end")'], + ]) + expect(val('A2')).toBe('start-a-b-c-end') + hf.destroy() + }) + }) + + describe('ignore_empty behavior', () => { + it('should skip truly empty cells when ignore_empty=TRUE', () => { + const {hf, val} = evaluate([ + ['hello', null, 'world', '=TEXTJOIN(",", TRUE(), A1:C1)'], + ]) + expect(val('D1')).toBe('hello,world') + hf.destroy() + }) + + it('should include truly empty cells when ignore_empty=FALSE', () => { + const {hf, val} = evaluate([ + ['hello', null, 'world', '=TEXTJOIN(",", FALSE(), A1:C1)'], + ]) + expect(val('D1')).toBe('hello,,world') + hf.destroy() + }) + + it('should skip cells containing ="" when ignore_empty=TRUE', () => { + const {hf, val} = evaluate([ + ['hello', '=""', 'world', '=TEXTJOIN(",", TRUE(), A1:C1)'], + ]) + expect(val('D1')).toBe('hello,world') + hf.destroy() + }) + + it('should include cells containing ="" when ignore_empty=FALSE', () => { + const {hf, val} = evaluate([ + ['hello', '=""', 'world', '=TEXTJOIN(",", FALSE(), A1:C1)'], + ]) + expect(val('D1')).toBe('hello,,world') + hf.destroy() + }) + + it('should return empty string for all-empty range with ignore_empty=TRUE', () => { + const {hf, val} = evaluate([ + [null, null, null, '=TEXTJOIN(",", TRUE(), A1:C1)'], + ]) + expect(val('D1')).toBe('') + hf.destroy() + }) + + it('should return delimiters for all-empty range with ignore_empty=FALSE', () => { + const {hf, val} = evaluate([ + [null, null, null, '=TEXTJOIN(",", FALSE(), A1:C1)'], + ]) + expect(val('D1')).toBe(',,') + hf.destroy() + }) + }) + + describe('array/range delimiter', () => { + it('should cycle through range delimiters with 3 text values', () => { + const {hf, val} = evaluate([ + ['-', '/', '=TEXTJOIN(A1:B1, TRUE(), "a", "b", "c")'], + ]) + expect(val('C1')).toBe('a-b/c') + hf.destroy() + }) + + it('should cycle through range delimiters with 4 text values', () => { + const {hf, val} = evaluate([ + ['-', '/', '=TEXTJOIN(A1:B1, TRUE(), "a", "b", "c", "d")'], + ]) + expect(val('C1')).toBe('a-b/c-d') + hf.destroy() + }) + + it('should use only first delimiter when there are 2 text values', () => { + const {hf, val} = evaluate([ + ['-', '/', '=TEXTJOIN(A1:B1, TRUE(), "p", "q")'], + ]) + expect(val('C1')).toBe('p-q') + hf.destroy() + }) + + it('should handle single-cell range as delimiter (no cycling)', () => { + const {hf, val} = evaluate([ + ['-', '=TEXTJOIN(A1:A1, TRUE(), "x", "y", "z")'], + ]) + expect(val('B1')).toBe('x-y-z') + hf.destroy() + }) + + it('should cycle delimiters with text range argument', () => { + const {hf, val} = evaluate([ + ['-', '/'], + ['a', 'b', 'c', 'd', 'e'], + ['=TEXTJOIN(A1:B1, TRUE(), A2:E2)'], + ]) + expect(val('A3')).toBe('a-b/c-d/e') + hf.destroy() + }) + + it('should handle vertical range as delimiter', () => { + const data = [ + ['-', 'a', 'b', 'c'], + ['/', '', '', '=TEXTJOIN(A1:A2, TRUE(), B1:D1)'], + ] + const {hf, val} = evaluate(data) + expect(val('D2')).toBe('a-b/c') + hf.destroy() + }) + }) + + describe('type coercion', () => { + it('should coerce number to string in delimiter position', () => { + const {hf, val} = evaluate([['=TEXTJOIN(1, TRUE(), "a", "b")']]) + expect(val('A1')).toBe('a1b') + hf.destroy() + }) + + it('should coerce cell reference to number as delimiter', () => { + const {hf, val} = evaluate([ + [42, '=TEXTJOIN(A1, TRUE(), "x", "y")'], + ]) + expect(val('B1')).toBe('x42y') + hf.destroy() + }) + + it('should coerce number in text position to string', () => { + const {hf, val} = evaluate([ + [42, 'hello', '=TEXTJOIN(",", TRUE(), A1, B1)'], + ]) + expect(val('C1')).toBe('42,hello') + hf.destroy() + }) + + it('should coerce boolean values in text position to strings', () => { + const {hf, val} = evaluate([ + ['=TEXTJOIN(",", TRUE(), TRUE(), FALSE(), "text")'], + ]) + expect(val('A1')).toBe('TRUE,FALSE,text') + hf.destroy() + }) + }) + + describe('error propagation', () => { + it('should propagate error from text range', () => { + const {hf, val} = evaluate([ + ['hello', '=1/0', 'world', '=TEXTJOIN(",", TRUE(), A1:C1)'], + ]) + const result = val('D1') + expect(result).toBeInstanceOf(DetailedCellError) + expect((result as DetailedCellError).type).toBe(ErrorType.DIV_BY_ZERO) + hf.destroy() + }) + + it('should propagate error from delimiter range', () => { + const {hf, val} = evaluate([ + ['=1/0', '/', '=TEXTJOIN(A1:B1, TRUE(), "a", "b", "c")'], + ]) + const result = val('C1') + expect(result).toBeInstanceOf(DetailedCellError) + expect((result as DetailedCellError).type).toBe(ErrorType.DIV_BY_ZERO) + hf.destroy() + }) + + it('should propagate #N/A from a text argument', () => { + const {hf, val} = evaluate([ + ['hello', '=NA()', 'world', '=TEXTJOIN(",", TRUE(), A1:C1)'], + ]) + const result = val('D1') + expect(result).toBeInstanceOf(DetailedCellError) + expect((result as DetailedCellError).type).toBe(ErrorType.NA) + hf.destroy() + }) + }) + + describe('edge cases', () => { + it('should handle range with numbers and empty cells with ignore_empty=TRUE', () => { + const {hf, val} = evaluate([ + ['hello', null, 'world', 42, '=""', '=TEXTJOIN(",", TRUE(), A1:E1)'], + ]) + expect(val('F1')).toBe('hello,world,42') + hf.destroy() + }) + + it('should return #VALUE! when result exceeds 32767 characters', () => { + // Create a string that will exceed the limit when joined + const longText = 'x'.repeat(16384) + const {hf, val} = evaluate([ + [longText, longText, '=TEXTJOIN("-", TRUE(), A1, B1)'], + ]) + const result = val('C1') + expect(result).toBeInstanceOf(DetailedCellError) + expect((result as DetailedCellError).type).toBe(ErrorType.VALUE) + hf.destroy() + }) + + it('should allow result exactly at 32767 characters', () => { + // 32767 total: two strings of 16383 chars + 1-char delimiter + const text = 'a'.repeat(16383) + const {hf, val} = evaluate([ + [text, text, '=TEXTJOIN("-", TRUE(), A1, B1)'], + ]) + const result = val('C1') as string + expect(typeof result).toBe('string') + expect(result.length).toBe(32767) + hf.destroy() + }) + + it('should handle multiple range arguments', () => { + const {hf, val} = evaluate([ + ['a', 'b'], + ['c', 'd'], + ['=TEXTJOIN(",", TRUE(), A1:B1, A2:B2)'], + ]) + expect(val('A3')).toBe('a,b,c,d') + hf.destroy() + }) + }) +}) From a1f9b9e3a47209b9a9ec360d8c8370dfc9aa55d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 19:10:07 +0000 Subject: [PATCH 04/14] Add TEXTJOIN to built-in functions documentation https://claude.ai/code/session_0138Q9YKLkHZ9geHUsEC6zFr --- docs/guide/built-in-functions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index dd9777523..922dbdf5a 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -500,6 +500,7 @@ Total number of functions: **{{ $page.functionsCount }}** | SEARCH | Returns the location of Search_string inside Text. Case-insensitive. Allows the use of wildcards. | SEARCH(Search_string, Text[, Start_position]) | | SPLIT | Divides the provided text using the space character as a separator and returns the substring at the zero-based position specified by the second argument.
`SPLIT("Lorem ipsum", 0) -> "Lorem"`
`SPLIT("Lorem ipsum", 1) -> "ipsum"` | SPLIT(Text, Index) | | 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]) | +| 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, ...]) | | 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) | | TRIM | Strips extra spaces from text. | TRIM("Text") | From 9af26889b7f5ca3b8c6a7ba26e5debe143ac29c7 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 26 Feb 2026 09:27:36 +0000 Subject: [PATCH 05/14] Refactor TEXTJOIN implementation using a private flattenArgToStrings helper --- src/interpreter/plugin/TextPlugin.ts | 159 ++++++++++++++------------- 1 file changed, 82 insertions(+), 77 deletions(-) diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index a6171c3dc..cb604166c 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -3,15 +3,15 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -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' -import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' +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' +import { FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions } from './FunctionPlugin' /** * Interpreter plugin containing text-specific functions @@ -21,7 +21,7 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec 'CONCATENATE': { method: 'concatenate', parameters: [ - {argumentType: FunctionArgumentType.STRING} + { argumentType: FunctionArgumentType.STRING } ], repeatLastArgs: 1, expandRanges: true, @@ -29,141 +29,141 @@ 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}, + { argumentType: FunctionArgumentType.ANY }, + { argumentType: FunctionArgumentType.BOOLEAN }, + { argumentType: FunctionArgumentType.ANY }, ], }, } @@ -448,39 +448,21 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec 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: string[] = [] - const delimiterValues = delimiterArg instanceof SimpleRangeValue - ? delimiterArg.valuesFromTopLeftCorner() - : [delimiterArg] - for (const val of delimiterValues) { - if (val instanceof CellError) { - return val - } - const coerced = coerceScalarToString(val as InternalScalarValue) - if (coerced instanceof CellError) { - return coerced - } - delimiters.push(coerced) + 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 values = arg instanceof SimpleRangeValue - ? arg.valuesFromTopLeftCorner() - : [arg] - for (const val of values) { - if (val instanceof CellError) { - return val - } - const coerced = coerceScalarToString(val as InternalScalarValue) - if (coerced instanceof CellError) { - return coerced - } - texts.push(coerced) + const coerced = this.flattenArgToStrings(arg) + if (coerced instanceof CellError) { + return coerced } + texts.push(...coerced) } const parts = ignoreEmpty ? texts.filter((t) => t !== '') : texts @@ -501,6 +483,29 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec ) } + /** + * 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. * From ac88fe3cc31c17568647b4c3c8564fcd76580ab2 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 26 Feb 2026 18:56:52 +0000 Subject: [PATCH 06/14] Add TEXTJOIN function with comprehensive tests and documentation --- src/interpreter/plugin/TextPlugin.ts | 96 ++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 12 deletions(-) diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index cb604166c..14214e97a 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -3,15 +3,15 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -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' -import { FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions } from './FunctionPlugin' +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' +import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' /** * Interpreter plugin containing text-specific functions @@ -161,9 +161,9 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec method: 'textjoin', repeatLastArgs: 1, parameters: [ - { argumentType: FunctionArgumentType.ANY }, - { argumentType: FunctionArgumentType.BOOLEAN }, - { argumentType: FunctionArgumentType.ANY }, + {argumentType: FunctionArgumentType.ANY}, + {argumentType: FunctionArgumentType.BOOLEAN}, + {argumentType: FunctionArgumentType.ANY}, ], }, } @@ -525,4 +525,76 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec private escapeRegExpSpecialCharacters(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } + + /** + * 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 '' + } + let result = parts[0] + for (let i = 1; i < parts.length; i++) { + result += delimiters[(i - 1) % delimiters.length] + parts[i] + } + + if (result.length > 32767) { + return new CellError(ErrorType.VALUE, ErrorMessage.TextJoinResultTooLong) + } + 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 + } } From 31d2cc47ba4608f569ab60bfb2305688602d8935 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 26 Feb 2026 19:55:43 +0000 Subject: [PATCH 07/14] Clean up minor code issues in TEXTJOIN implementation - Remove dead code: redundant CellError check after coerceScalarToString in flattenArgToStrings - Remove empty no-op afterEach callback from textjoin test suite --- src/interpreter/plugin/TextPlugin.ts | 6 +----- test/textjoin.spec.ts | 4 ---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index 14214e97a..3ef40a0ab 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -589,11 +589,7 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec if (val instanceof CellError) { return val } - const coerced = coerceScalarToString(val as InternalScalarValue) - if (coerced instanceof CellError) { - return coerced - } - result.push(coerced) + result.push(coerceScalarToString(val as InternalScalarValue) as string) } return result } diff --git a/test/textjoin.spec.ts b/test/textjoin.spec.ts index df5c8b714..49d8a8d59 100644 --- a/test/textjoin.spec.ts +++ b/test/textjoin.spec.ts @@ -10,10 +10,6 @@ describe('TEXTJOIN', () => { return {hf, val: (ref: string) => hf.getCellValue(adr(ref))} } - afterEach(() => { - // HyperFormula instances are destroyed in each test - }) - describe('basic functionality', () => { it('should join literal strings with a scalar delimiter', () => { const {hf, val} = evaluate([['=TEXTJOIN(", ", TRUE(), "Hello", "World")']]) From cbbbcaf569e6d69a9d4150412b7a283843882581 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 3 Mar 2026 08:18:43 +0000 Subject: [PATCH 08/14] Move TEXTJOIN tests from smoke.spec.ts to textjoin.spec.ts Per code review: smoke.spec.ts should stay minimal. All TEXTJOIN tests are now consolidated in textjoin.spec.ts, covering individual cell references, single-ref args, and explicit empty-string vs null cell behaviour for ignore_empty=TRUE/FALSE. --- test/smoke.spec.ts | 96 ------------------------------------------- test/textjoin.spec.ts | 44 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 96 deletions(-) diff --git a/test/smoke.spec.ts b/test/smoke.spec.ts index efadb493c..1e520c1b9 100644 --- a/test/smoke.spec.ts +++ b/test/smoke.spec.ts @@ -63,102 +63,6 @@ describe('HyperFormula', () => { hf.destroy() }) - it('should evaluate TEXTJOIN with ignore_empty=TRUE', () => { - const data = [ - ['Hello', 'World', '', '=TEXTJOIN(", ", TRUE(), A1:C1)'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue(adr('D1'))).toBe('Hello, World') - - hf.destroy() - }) - - it('should evaluate TEXTJOIN with ignore_empty=FALSE', () => { - const data = [ - ['Hello', 'World', '', '=TEXTJOIN(", ", FALSE(), A1:C1)'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue(adr('D1'))).toBe('Hello, World, ') - - hf.destroy() - }) - - it('should evaluate TEXTJOIN with individual cell references', () => { - const data = [ - ['a', 'b', 'c', '=TEXTJOIN("-", TRUE(), A1, B1, C1)'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue(adr('D1'))).toBe('a-b-c') - - hf.destroy() - }) - - it('should evaluate TEXTJOIN with single argument', () => { - const data = [ - ['only', '=TEXTJOIN(",", TRUE(), A1)'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue(adr('B1'))).toBe('only') - - hf.destroy() - }) - - it('should evaluate TEXTJOIN with empty delimiter', () => { - const data = [ - ['x', 'y', 'z', '=TEXTJOIN("", TRUE(), A1:C1)'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue(adr('D1'))).toBe('xyz') - - hf.destroy() - }) - - it('should evaluate TEXTJOIN with all-empty range and ignore_empty=TRUE', () => { - const data = [ - ['', '', '', '=TEXTJOIN(",", TRUE(), A1:C1)'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue(adr('D1'))).toBe('') - - hf.destroy() - }) - - it('should evaluate TEXTJOIN with all-empty range and ignore_empty=FALSE', () => { - const data = [ - ['', '', '', '=TEXTJOIN(",", FALSE(), A1:C1)'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue(adr('D1'))).toBe(',,') - - hf.destroy() - }) - - it('should evaluate TEXTJOIN with array delimiter (cycling)', () => { - const data = [ - ['-', '/', '=TEXTJOIN(A1:B1, TRUE(), "a", "b", "c", "d")'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue(adr('C1'))).toBe('a-b/c-d') - - hf.destroy() - }) - it('should add and remove rows with formula updates', () => { const data = [ [1], diff --git a/test/textjoin.spec.ts b/test/textjoin.spec.ts index 49d8a8d59..115e899f0 100644 --- a/test/textjoin.spec.ts +++ b/test/textjoin.spec.ts @@ -49,6 +49,18 @@ describe('TEXTJOIN', () => { expect(val('A2')).toBe('start-a-b-c-end') hf.destroy() }) + + it('should join individual cell references', () => { + const {hf, val} = evaluate([['a', 'b', 'c', '=TEXTJOIN("-", TRUE(), A1, B1, C1)']]) + expect(val('D1')).toBe('a-b-c') + hf.destroy() + }) + + it('should return single cell reference value without delimiter', () => { + const {hf, val} = evaluate([['only', '=TEXTJOIN(",", TRUE(), A1)']]) + expect(val('B1')).toBe('only') + hf.destroy() + }) }) describe('ignore_empty behavior', () => { @@ -84,6 +96,22 @@ describe('TEXTJOIN', () => { hf.destroy() }) + it('should skip empty string cells when ignore_empty=TRUE', () => { + const {hf, val} = evaluate([ + ['Hello', 'World', '', '=TEXTJOIN(", ", TRUE(), A1:C1)'], + ]) + expect(val('D1')).toBe('Hello, World') + hf.destroy() + }) + + it('should include empty string cells when ignore_empty=FALSE', () => { + const {hf, val} = evaluate([ + ['Hello', 'World', '', '=TEXTJOIN(", ", FALSE(), A1:C1)'], + ]) + expect(val('D1')).toBe('Hello, World, ') + hf.destroy() + }) + it('should return empty string for all-empty range with ignore_empty=TRUE', () => { const {hf, val} = evaluate([ [null, null, null, '=TEXTJOIN(",", TRUE(), A1:C1)'], @@ -92,6 +120,14 @@ describe('TEXTJOIN', () => { hf.destroy() }) + it('should return delimiters for all-empty string range with ignore_empty=TRUE', () => { + const {hf, val} = evaluate([ + ['', '', '', '=TEXTJOIN(",", TRUE(), A1:C1)'], + ]) + expect(val('D1')).toBe('') + hf.destroy() + }) + it('should return delimiters for all-empty range with ignore_empty=FALSE', () => { const {hf, val} = evaluate([ [null, null, null, '=TEXTJOIN(",", FALSE(), A1:C1)'], @@ -99,6 +135,14 @@ describe('TEXTJOIN', () => { expect(val('D1')).toBe(',,') hf.destroy() }) + + it('should return delimiters for all-empty string range with ignore_empty=FALSE', () => { + const {hf, val} = evaluate([ + ['', '', '', '=TEXTJOIN(",", FALSE(), A1:C1)'], + ]) + expect(val('D1')).toBe(',,') + hf.destroy() + }) }) describe('array/range delimiter', () => { From 6438c24bcdcf32ad1828f8b565f96f690066fa74 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 18 Mar 2026 08:32:31 +0000 Subject: [PATCH 09/14] Move TEXTJOIN tests to private repo, fix docs alphabetical order - Delete test/textjoin.spec.ts (tests moved to hyperformula-tests) - Fix TEXTJOIN position in built-in-functions.md: move between TEXT and TRIM --- docs/guide/built-in-functions.md | 2 +- test/textjoin.spec.ts | 309 ------------------------------- 2 files changed, 1 insertion(+), 310 deletions(-) delete mode 100644 test/textjoin.spec.ts diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index 922dbdf5a..eb8428e18 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -500,9 +500,9 @@ Total number of functions: **{{ $page.functionsCount }}** | SEARCH | Returns the location of Search_string inside Text. Case-insensitive. Allows the use of wildcards. | SEARCH(Search_string, Text[, Start_position]) | | SPLIT | Divides the provided text using the space character as a separator and returns the substring at the zero-based position specified by the second argument.
`SPLIT("Lorem ipsum", 0) -> "Lorem"`
`SPLIT("Lorem ipsum", 1) -> "ipsum"` | SPLIT(Text, Index) | | 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]) | -| 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, ...]) | | 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/test/textjoin.spec.ts b/test/textjoin.spec.ts deleted file mode 100644 index 115e899f0..000000000 --- a/test/textjoin.spec.ts +++ /dev/null @@ -1,309 +0,0 @@ -import {HyperFormula} from '../src' -import {ErrorType} from '../src/Cell' -import {DetailedCellError} from '../src/CellValue' -import {adr} from './testUtils' - -describe('TEXTJOIN', () => { - // Helper to build a single-sheet engine and return cell value - const evaluate = (data: any[][]) => { - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - return {hf, val: (ref: string) => hf.getCellValue(adr(ref))} - } - - describe('basic functionality', () => { - it('should join literal strings with a scalar delimiter', () => { - const {hf, val} = evaluate([['=TEXTJOIN(", ", TRUE(), "Hello", "World")']]) - expect(val('A1')).toBe('Hello, World') - hf.destroy() - }) - - it('should join a range of cells', () => { - const {hf, val} = evaluate([['a', 'b', 'c', '=TEXTJOIN("-", TRUE(), A1:C1)']]) - expect(val('D1')).toBe('a-b-c') - hf.destroy() - }) - - it('should concatenate when delimiter is empty string', () => { - const {hf, val} = evaluate([['x', 'y', 'z', '=TEXTJOIN("", TRUE(), A1:C1)']]) - expect(val('D1')).toBe('xyz') - hf.destroy() - }) - - it('should handle multi-character delimiter', () => { - const {hf, val} = evaluate([['a', 'b', 'c', '=TEXTJOIN(", ", TRUE(), A1:C1)']]) - expect(val('D1')).toBe('a, b, c') - hf.destroy() - }) - - it('should return single text value without delimiter', () => { - const {hf, val} = evaluate([['=TEXTJOIN(",", TRUE(), "only")']]) - expect(val('A1')).toBe('only') - hf.destroy() - }) - - it('should join mixed scalar and range arguments', () => { - const {hf, val} = evaluate([ - ['a', 'b', 'c'], - ['=TEXTJOIN("-", TRUE(), "start", A1:C1, "end")'], - ]) - expect(val('A2')).toBe('start-a-b-c-end') - hf.destroy() - }) - - it('should join individual cell references', () => { - const {hf, val} = evaluate([['a', 'b', 'c', '=TEXTJOIN("-", TRUE(), A1, B1, C1)']]) - expect(val('D1')).toBe('a-b-c') - hf.destroy() - }) - - it('should return single cell reference value without delimiter', () => { - const {hf, val} = evaluate([['only', '=TEXTJOIN(",", TRUE(), A1)']]) - expect(val('B1')).toBe('only') - hf.destroy() - }) - }) - - describe('ignore_empty behavior', () => { - it('should skip truly empty cells when ignore_empty=TRUE', () => { - const {hf, val} = evaluate([ - ['hello', null, 'world', '=TEXTJOIN(",", TRUE(), A1:C1)'], - ]) - expect(val('D1')).toBe('hello,world') - hf.destroy() - }) - - it('should include truly empty cells when ignore_empty=FALSE', () => { - const {hf, val} = evaluate([ - ['hello', null, 'world', '=TEXTJOIN(",", FALSE(), A1:C1)'], - ]) - expect(val('D1')).toBe('hello,,world') - hf.destroy() - }) - - it('should skip cells containing ="" when ignore_empty=TRUE', () => { - const {hf, val} = evaluate([ - ['hello', '=""', 'world', '=TEXTJOIN(",", TRUE(), A1:C1)'], - ]) - expect(val('D1')).toBe('hello,world') - hf.destroy() - }) - - it('should include cells containing ="" when ignore_empty=FALSE', () => { - const {hf, val} = evaluate([ - ['hello', '=""', 'world', '=TEXTJOIN(",", FALSE(), A1:C1)'], - ]) - expect(val('D1')).toBe('hello,,world') - hf.destroy() - }) - - it('should skip empty string cells when ignore_empty=TRUE', () => { - const {hf, val} = evaluate([ - ['Hello', 'World', '', '=TEXTJOIN(", ", TRUE(), A1:C1)'], - ]) - expect(val('D1')).toBe('Hello, World') - hf.destroy() - }) - - it('should include empty string cells when ignore_empty=FALSE', () => { - const {hf, val} = evaluate([ - ['Hello', 'World', '', '=TEXTJOIN(", ", FALSE(), A1:C1)'], - ]) - expect(val('D1')).toBe('Hello, World, ') - hf.destroy() - }) - - it('should return empty string for all-empty range with ignore_empty=TRUE', () => { - const {hf, val} = evaluate([ - [null, null, null, '=TEXTJOIN(",", TRUE(), A1:C1)'], - ]) - expect(val('D1')).toBe('') - hf.destroy() - }) - - it('should return delimiters for all-empty string range with ignore_empty=TRUE', () => { - const {hf, val} = evaluate([ - ['', '', '', '=TEXTJOIN(",", TRUE(), A1:C1)'], - ]) - expect(val('D1')).toBe('') - hf.destroy() - }) - - it('should return delimiters for all-empty range with ignore_empty=FALSE', () => { - const {hf, val} = evaluate([ - [null, null, null, '=TEXTJOIN(",", FALSE(), A1:C1)'], - ]) - expect(val('D1')).toBe(',,') - hf.destroy() - }) - - it('should return delimiters for all-empty string range with ignore_empty=FALSE', () => { - const {hf, val} = evaluate([ - ['', '', '', '=TEXTJOIN(",", FALSE(), A1:C1)'], - ]) - expect(val('D1')).toBe(',,') - hf.destroy() - }) - }) - - describe('array/range delimiter', () => { - it('should cycle through range delimiters with 3 text values', () => { - const {hf, val} = evaluate([ - ['-', '/', '=TEXTJOIN(A1:B1, TRUE(), "a", "b", "c")'], - ]) - expect(val('C1')).toBe('a-b/c') - hf.destroy() - }) - - it('should cycle through range delimiters with 4 text values', () => { - const {hf, val} = evaluate([ - ['-', '/', '=TEXTJOIN(A1:B1, TRUE(), "a", "b", "c", "d")'], - ]) - expect(val('C1')).toBe('a-b/c-d') - hf.destroy() - }) - - it('should use only first delimiter when there are 2 text values', () => { - const {hf, val} = evaluate([ - ['-', '/', '=TEXTJOIN(A1:B1, TRUE(), "p", "q")'], - ]) - expect(val('C1')).toBe('p-q') - hf.destroy() - }) - - it('should handle single-cell range as delimiter (no cycling)', () => { - const {hf, val} = evaluate([ - ['-', '=TEXTJOIN(A1:A1, TRUE(), "x", "y", "z")'], - ]) - expect(val('B1')).toBe('x-y-z') - hf.destroy() - }) - - it('should cycle delimiters with text range argument', () => { - const {hf, val} = evaluate([ - ['-', '/'], - ['a', 'b', 'c', 'd', 'e'], - ['=TEXTJOIN(A1:B1, TRUE(), A2:E2)'], - ]) - expect(val('A3')).toBe('a-b/c-d/e') - hf.destroy() - }) - - it('should handle vertical range as delimiter', () => { - const data = [ - ['-', 'a', 'b', 'c'], - ['/', '', '', '=TEXTJOIN(A1:A2, TRUE(), B1:D1)'], - ] - const {hf, val} = evaluate(data) - expect(val('D2')).toBe('a-b/c') - hf.destroy() - }) - }) - - describe('type coercion', () => { - it('should coerce number to string in delimiter position', () => { - const {hf, val} = evaluate([['=TEXTJOIN(1, TRUE(), "a", "b")']]) - expect(val('A1')).toBe('a1b') - hf.destroy() - }) - - it('should coerce cell reference to number as delimiter', () => { - const {hf, val} = evaluate([ - [42, '=TEXTJOIN(A1, TRUE(), "x", "y")'], - ]) - expect(val('B1')).toBe('x42y') - hf.destroy() - }) - - it('should coerce number in text position to string', () => { - const {hf, val} = evaluate([ - [42, 'hello', '=TEXTJOIN(",", TRUE(), A1, B1)'], - ]) - expect(val('C1')).toBe('42,hello') - hf.destroy() - }) - - it('should coerce boolean values in text position to strings', () => { - const {hf, val} = evaluate([ - ['=TEXTJOIN(",", TRUE(), TRUE(), FALSE(), "text")'], - ]) - expect(val('A1')).toBe('TRUE,FALSE,text') - hf.destroy() - }) - }) - - describe('error propagation', () => { - it('should propagate error from text range', () => { - const {hf, val} = evaluate([ - ['hello', '=1/0', 'world', '=TEXTJOIN(",", TRUE(), A1:C1)'], - ]) - const result = val('D1') - expect(result).toBeInstanceOf(DetailedCellError) - expect((result as DetailedCellError).type).toBe(ErrorType.DIV_BY_ZERO) - hf.destroy() - }) - - it('should propagate error from delimiter range', () => { - const {hf, val} = evaluate([ - ['=1/0', '/', '=TEXTJOIN(A1:B1, TRUE(), "a", "b", "c")'], - ]) - const result = val('C1') - expect(result).toBeInstanceOf(DetailedCellError) - expect((result as DetailedCellError).type).toBe(ErrorType.DIV_BY_ZERO) - hf.destroy() - }) - - it('should propagate #N/A from a text argument', () => { - const {hf, val} = evaluate([ - ['hello', '=NA()', 'world', '=TEXTJOIN(",", TRUE(), A1:C1)'], - ]) - const result = val('D1') - expect(result).toBeInstanceOf(DetailedCellError) - expect((result as DetailedCellError).type).toBe(ErrorType.NA) - hf.destroy() - }) - }) - - describe('edge cases', () => { - it('should handle range with numbers and empty cells with ignore_empty=TRUE', () => { - const {hf, val} = evaluate([ - ['hello', null, 'world', 42, '=""', '=TEXTJOIN(",", TRUE(), A1:E1)'], - ]) - expect(val('F1')).toBe('hello,world,42') - hf.destroy() - }) - - it('should return #VALUE! when result exceeds 32767 characters', () => { - // Create a string that will exceed the limit when joined - const longText = 'x'.repeat(16384) - const {hf, val} = evaluate([ - [longText, longText, '=TEXTJOIN("-", TRUE(), A1, B1)'], - ]) - const result = val('C1') - expect(result).toBeInstanceOf(DetailedCellError) - expect((result as DetailedCellError).type).toBe(ErrorType.VALUE) - hf.destroy() - }) - - it('should allow result exactly at 32767 characters', () => { - // 32767 total: two strings of 16383 chars + 1-char delimiter - const text = 'a'.repeat(16383) - const {hf, val} = evaluate([ - [text, text, '=TEXTJOIN("-", TRUE(), A1, B1)'], - ]) - const result = val('C1') as string - expect(typeof result).toBe('string') - expect(result.length).toBe(32767) - hf.destroy() - }) - - it('should handle multiple range arguments', () => { - const {hf, val} = evaluate([ - ['a', 'b'], - ['c', 'd'], - ['=TEXTJOIN(",", TRUE(), A1:B1, A2:B2)'], - ]) - expect(val('A3')).toBe('a,b,c,d') - hf.destroy() - }) - }) -}) From 3f111b095345a8f551a494f39ba4367cd8e71af7 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 18 Mar 2026 08:36:48 +0000 Subject: [PATCH 10/14] Fix unsafe type cast in flattenArgToStrings, remove duplicate methods - Replace `as string` cast with explicit CellError check on coerceScalarToString return value - Remove duplicate textjoin/flattenArgToStrings methods introduced during rebase --- src/interpreter/plugin/TextPlugin.ts | 67 ---------------------------- 1 file changed, 67 deletions(-) diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index 3ef40a0ab..6611bb080 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -526,71 +526,4 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } - /** - * 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 '' - } - let result = parts[0] - for (let i = 1; i < parts.length; i++) { - result += delimiters[(i - 1) % delimiters.length] + parts[i] - } - - if (result.length > 32767) { - return new CellError(ErrorType.VALUE, ErrorMessage.TextJoinResultTooLong) - } - 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 - } - result.push(coerceScalarToString(val as InternalScalarValue) as string) - } - return result - } } From 7aca32c832795882f91f3b53907cc725df15719c Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 18 Mar 2026 09:33:13 +0000 Subject: [PATCH 11/14] ci: retrigger CI after hyperformula-tests merge with develop From 6605431abe5c3c065b4c5106549dc26d530fd649 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 26 Mar 2026 08:40:44 +0000 Subject: [PATCH 12/14] Address code review: CHANGELOG, generic error message, functional style - Add CHANGELOG entry for TEXTJOIN under [Unreleased] - Rename TextJoinResultTooLong to ResultTooLong with generic message - Refactor textjoin and flattenArgToStrings to use reduce instead of for loops Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 +++ src/error-message.ts | 2 +- src/interpreter/plugin/TextPlugin.ts | 41 ++++++++++++++-------------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7ab58d2..84090ee80 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/src/error-message.ts b/src/error-message.ts index dc8db42dc..5e3afdbea 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -73,7 +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 TextJoinResultTooLong = 'TEXTJOIN result exceeds the maximum allowed length of 32,767 characters.' + 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/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index 6611bb080..2e93e85c0 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -456,27 +456,30 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec return delimiters } - const texts: string[] = [] - for (const arg of textArgs) { - const coerced = this.flattenArgToStrings(arg) - if (coerced instanceof CellError) { - return coerced + const textsOrError = textArgs.reduce((acc, arg) => { + if (acc instanceof CellError) { + return acc } - texts.push(...coerced) + const coerced = this.flattenArgToStrings(arg) + return coerced instanceof CellError ? coerced : [...acc, ...coerced] + }, []) + + if (textsOrError instanceof CellError) { + return textsOrError } - const parts = ignoreEmpty ? texts.filter((t) => t !== '') : texts + const parts = ignoreEmpty ? textsOrError.filter((t) => t !== '') : textsOrError if (parts.length === 0) { return '' } - let result = parts[0] - for (let i = 1; i < parts.length; i++) { - result += delimiters[(i - 1) % delimiters.length] + parts[i] - } + + 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.TextJoinResultTooLong) + return new CellError(ErrorType.VALUE, ErrorMessage.ResultTooLong) } return result } @@ -492,18 +495,16 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec */ private flattenArgToStrings(arg: InternalScalarValue | SimpleRangeValue): string[] | CellError { const values = arg instanceof SimpleRangeValue ? arg.valuesFromTopLeftCorner() : [arg] - const result: string[] = [] - for (const val of values) { + return values.reduce((acc, val) => { + if (acc instanceof CellError) { + return acc + } if (val instanceof CellError) { return val } const coerced = coerceScalarToString(val as InternalScalarValue) - if (coerced instanceof CellError) { - return coerced - } - result.push(coerced) - } - return result + return coerced instanceof CellError ? coerced : [...acc, coerced] + }, []) } /** From 9fc1dc25146c617349338698f4644c0da182d4c9 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 26 Mar 2026 08:56:51 +0000 Subject: [PATCH 13/14] Remove unreachable coerceScalarToString error branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CellError values are caught before coerceScalarToString is called, so it only ever receives string/number/boolean/EmptyValue — all of which return strings. Removes the dead branch to fix coverage gap. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/interpreter/plugin/TextPlugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index 2e93e85c0..42eb99a71 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -502,8 +502,7 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec if (val instanceof CellError) { return val } - const coerced = coerceScalarToString(val as InternalScalarValue) - return coerced instanceof CellError ? coerced : [...acc, coerced] + return [...acc, coerceScalarToString(val as InternalScalarValue) as string] }, []) } From 6d563e5cc35862f101282fea63b554e11e066f44 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 26 Mar 2026 09:06:47 +0000 Subject: [PATCH 14/14] =?UTF-8?q?Revert=20to=20imperative=20loops=20to=20f?= =?UTF-8?q?ix=20O(N=C2=B2)=20array=20spread=20in=20reduce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The functional reduce with [...acc, coerced] copied the entire array on every iteration, causing O(N²) performance for large ranges. Imperative loops with push are O(N) and clearer for early-error-return patterns. Restores the defensive coerceScalarToString error check. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/interpreter/plugin/TextPlugin.ts | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index 42eb99a71..4326a57d6 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -456,19 +456,16 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec return delimiters } - const textsOrError = textArgs.reduce((acc, arg) => { - if (acc instanceof CellError) { - return acc - } + const texts: string[] = [] + for (const arg of textArgs) { const coerced = this.flattenArgToStrings(arg) - return coerced instanceof CellError ? coerced : [...acc, ...coerced] - }, []) - - if (textsOrError instanceof CellError) { - return textsOrError + if (coerced instanceof CellError) { + return coerced + } + texts.push(...coerced) } - const parts = ignoreEmpty ? textsOrError.filter((t) => t !== '') : textsOrError + const parts = ignoreEmpty ? texts.filter((t) => t !== '') : texts if (parts.length === 0) { return '' @@ -495,15 +492,18 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec */ private flattenArgToStrings(arg: InternalScalarValue | SimpleRangeValue): string[] | CellError { const values = arg instanceof SimpleRangeValue ? arg.valuesFromTopLeftCorner() : [arg] - return values.reduce((acc, val) => { - if (acc instanceof CellError) { - return acc - } + const result: string[] = [] + for (const val of values) { if (val instanceof CellError) { return val } - return [...acc, coerceScalarToString(val as InternalScalarValue) as string] - }, []) + const coerced = coerceScalarToString(val as InternalScalarValue) + if (coerced instanceof CellError) { + return coerced + } + result.push(coerced) + } + return result } /**