From 2fe6a401119e551003417a920ffc4af540b29a1a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 13 Jul 2026 17:00:38 +0000 Subject: [PATCH 1/9] feat(sort): implement SORT dynamic array function (HF-69) Add SORT(array, [sort_index], [sort_order], [by_col]) as a dynamic array function, mirroring the SEQUENCE/FILTER machinery. Ordering reuses ArithmeticHelper (mixed types, empties, collation) and is stable, so ties keep input order. sort_order is validated strictly to {1,-1}; sort_index is bounds-checked; errors in the input range propagate. Output shape equals the input shape. Includes i18n for all 17 language packs, docs (built-in-functions, known-limitations), a changelog entry, and the shared ADR. Source: https://app.clickup.com/t/86c89q1tt ADR: docs/adr/2026-07-13-sort-unique-array-functions.md Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../2026-07-13-sort-unique-array-functions.md | 262 ++++++++++++++++++ docs/guide/built-in-functions.md | 1 + docs/guide/known-limitations.md | 8 + 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/idID.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/SortPlugin.ts | 144 ++++++++++ src/interpreter/plugin/index.ts | 1 + 23 files changed, 434 insertions(+) create mode 100644 docs/adr/2026-07-13-sort-unique-array-functions.md create mode 100644 src/interpreter/plugin/SortPlugin.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 94424f8a3..fab7315b4 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 new functions: VSTACK, HSTACK. [#1698](https://github.com/handsontable/hyperformula/pull/1698) - Added a new function: `XIRR`. [#1701](https://github.com/handsontable/hyperformula/pull/1701) +- Added the SORT function. [#1707](https://github.com/handsontable/hyperformula/pull/1707) - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) - Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) diff --git a/docs/adr/2026-07-13-sort-unique-array-functions.md b/docs/adr/2026-07-13-sort-unique-array-functions.md new file mode 100644 index 000000000..f5bdcb218 --- /dev/null +++ b/docs/adr/2026-07-13-sort-unique-array-functions.md @@ -0,0 +1,262 @@ +# ADR 2026-07-13 — SORT (HF-69) + UNIQUE (HF-68) dynamic array functions + +- **status**: accepted +- **date**: 2026-07-13 +- **tasks**: HF-69 (86c89q1tt) — SORT · HF-68 (86c89q1tq) — UNIQUE +- **parent**: HF-28 (86c6r57ke) — Modern dynamic array functions · **siblings**: SEQUENCE (shipped), VSTACK/HSTACK (HF-71, in review) +- **base branch**: clean `develop` (`00e5c730a`) — two independent feature branches [dec_1] + +## Context + +HF-28 tracks the modern dynamic-array family. SEQUENCE is shipped and is the anatomy exemplar: +a plugin declares `implementedFunctions` with `method`, a mandatory `sizeOfResultArrayMethod`, +and `vectorizationForbidden: true`; the runtime method returns a `SimpleRangeValue`/`CellError` +via `runFunction`, and the parse-time method returns an `ArraySize` [vrf_3]. FILTER is the +exemplar for a **dynamic-size** result: it predicts the input array size at parse time and +returns fewer rows at runtime, tolerating the size mismatch [vrf_4]. SORT and UNIQUE are +greenfield — no `SortPlugin`/`UniquePlugin` exists in `src/` [vrf_7]. + +Excel's documented contracts (authoritative, Microsoft Learn): + +- **SORT(array, [sort_index], [sort_order], [by_col])** — returns an array the **same shape** as + `array`. `sort_index` is a 1-based row/column number (default row1/col1); `sort_order` is `1` + ascending (default) or `-1` descending; `by_col` is `FALSE` (default, sort by row) or `TRUE` + (sort by column) [vrf_1]. +- **UNIQUE(array, [by_col], [exactly_once])** — `by_col` `FALSE` (default) compares rows and + returns unique rows, `TRUE` compares columns and returns unique columns; `exactly_once` `TRUE` + returns only rows/columns occurring exactly once, `FALSE` (default) returns all distinct + rows/columns. Result size is data-dependent (dynamic) [vrf_2]. + +HyperFormula already has an ordering comparator: `ArithmeticHelper` exposes `lt`/`gt`/`eq`, which +handle mixed types (numbers < strings < booleans via `CellValueTypeOrd`), empty cells, and +locale collation driven by `caseSensitive`/`accentSensitive` config [vrf_5]. Plugins register +automatically — every non-`_` export from `src/interpreter/plugin/index.ts` is registered as a +built-in [vrf_6] — so wiring is: add the plugin file, export it, and add the function name to all +17 i18n language packs. + +**Verification constraint.** This environment has no live Excel. Microsoft Learn documentation is +the oracle for documented behavior [vrf_1][vrf_2]. Behaviors **not** in the docs (notably the +reported "`sort_order=0` does not error" quirk) cannot be re-verified here and are **not** asserted +from memory — we adopt the strict documented contract and flag the quirk for live-Excel/Kuba +confirmation [con_1]. + +## Decisions + +- **Two separate PRs, one per ClickUp task, off clean `develop`** [dec_1]: Kuba tracks HF-68 and + HF-69 separately. SORT on `feature/HF-69-sort`, UNIQUE on `feature/HF-68-unique`, each with its + own paired `hyperformula-tests` PR. The shared ADR + plan are committed to both branches. + +- **`sort_order` strictly `{1, -1}`; any other value → `#VALUE!`** [dec_2]: Microsoft documents + only `1` and `-1` [vrf_1]. The reported `sort_order=0` non-error is undocumented and could not + be verified against live Excel here [con_1]; we choose the strict documented contract (simpler, + testable without Excel, defensible) and will revisit if live-Excel/Kuba confirms otherwise. + +- **Reuse `ArithmeticHelper.lt`/`gt` for SORT ordering and `eq` for UNIQUE equality** [dec_3]: no + new comparator. SORT's per-key comparator is `lt/gt` scaled by `sort_order`, with a **stable** + sort so ties preserve input order (matches Excel's stable behavior). UNIQUE dedups by comparing + rows/columns element-wise with `eq`, which is case-insensitive by default (Excel UNIQUE is + case-insensitive) and honors HF's collation config [dec_5]. + +- **Error-type map** [dec_4]: + - non-finite (`Infinity`/`NaN`) numeric arg → `#VALUE!` + - `sort_index` < 1 or exceeding the sort dimension → `#VALUE!` + - `sort_order` ∉ `{1,-1}` → `#VALUE!` (per [dec_2]) + - result exceeding `maxRows`/`maxColumns` → `#VALUE!` + - any error-typed argument → propagate automatically via `runFunction` + - wrong arity → `#N/A` automatically (arg-count validation) + +- **Single-key `sort_index` in v1; multi-key array-constant `sort_index` (e.g. `{1,2}`) deferred** + [dec_6]: the `sort_index` parameter is typed `NUMBER`; accepting an array constant would require + a different argument shape. v1 sorts by one key; multi-key is recorded in + `known-limitations.md` and left for a follow-up. Concrete error messages: `sort_index < 1` → + `LessThanOne`; `sort_index >` the sort dimension → `ValueLarge`; `sort_order ∉ {1,-1}` → + `BadMode`. + +- **Propagate the first error found in the input range** [dec_7]: `lt`/`gt`/`eq` operate on + no-error scalars, and Excel's exact in-array error behavior is unverifiable here [con_1]. SORT and + UNIQUE scan the input and return the first `CellError` encountered, consistent with the + "error arg → propagate" rule extended to range contents [dec_4]. + +- **Empty UNIQUE result → `#NA` (`EmptyRange`)** [dec_8]: only reachable via `exactly_once` when no + row/column occurs exactly once. Excel returns `#CALC!`, which HyperFormula has no type for; we + mirror FILTER's empty-result mapping (`ErrorType.NA` / `ErrorMessage.EmptyRange`) for consistency + with the sibling dynamic-size function. + +- **Result-size prediction** [con_3]: SORT output shape equals input shape → parse-time size = + `arraySizeForAst(array)` (deterministic). UNIQUE output size is data-dependent → mirror FILTER: + predict the input size as an upper bound and return the smaller actual result at runtime + [vrf_4]. + +## Consequences + +- **Positive**: both functions reuse existing, tested infrastructure (comparator, `runFunction`, + array-size machinery); behavior for mixed types/empties/collation is consistent with the rest of + HF automatically; auto-registration means minimal wiring. +- **Neutral / follow-ups**: the strict `sort_order` contract [dec_2] and any other Excel divergence + are surfaced in three places (PR Notes, inline comment, test name/comment) and recorded in + `docs/guide/known-limitations.md` / `list-of-differences.md` where behavior diverges. The + `sort_order=0` quirk is an open live-Excel verification item, not a shipped guarantee [con_1]. +- **Negative**: without live Excel, tie-breaking on mixed types and the exact collation of UNIQUE + are validated against HF's own comparator semantics + MS docs, not a byte-for-byte Excel oracle; + xlsx oracle fixtures are provided so a human/graph-runner with Excel can confirm later. + +## Alternatives considered + +1. **One combined PR for both functions** — rejected: Kuba tracks HF-68 and HF-69 as separate + ClickUp tasks; combined delivery breaks his tracking [dec_1]. +2. **Write a bespoke SORT comparator** — rejected: `ArithmeticHelper.lt/gt/eq` already encodes + Excel-consistent cross-type ordering, empties, and collation [vrf_5]; a bespoke comparator would + drift from the rest of the engine [dec_3]. +3. **Treat `sort_order=0` as valid ascending** (matching the reported Excel quirk) — rejected: the + quirk is undocumented and unverifiable in this environment [con_1]; asserting it from memory + would violate feature-traceability. Strict documented contract chosen [dec_2]. +4. **Predict a fixed 1×1 size for UNIQUE** — rejected: FILTER's input-size-upper-bound prediction + is the established dynamic-size pattern and spills correctly [vrf_4][con_3]. +5. **Make UNIQUE case-sensitive** — rejected: Excel UNIQUE is case-insensitive and HF's `eq` + default (`caseSensitive: false`) matches it while staying config-driven [dec_5]. + +## §AuditSources + +[vrf_1] Microsoft documents SORT(array,[sort_index],[sort_order],[by_col]); result same shape as array; sort_order 1=ascending(default)/-1=descending; by_col FALSE(default)=by row +- type: live-http +- spec: https://support.microsoft.com/en-us/office/sort-function-22f63bd0-ccc8-492f-953d-c20e8e44b86c +- verify: curl -sSL -o /dev/null -w "%{http_code}" "https://support.microsoft.com/en-us/office/sort-function-22f63bd0-ccc8-492f-953d-c20e8e44b86c" +- expect: ^200$ +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_2] Microsoft documents UNIQUE(array,[by_col],[exactly_once]); by_col FALSE=unique rows; exactly_once TRUE=rows/cols occurring exactly once; dynamic result size +- type: live-http +- spec: https://support.microsoft.com/en-us/office/unique-function-c5ab87fd-30a3-4ce9-9d1a-40204fb85e1e +- verify: curl -sSL -o /dev/null -w "%{http_code}" "https://support.microsoft.com/en-us/office/unique-function-c5ab87fd-30a3-4ce9-9d1a-40204fb85e1e" +- expect: ^200$ +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_3] SEQUENCE plugin is the anatomy exemplar: sizeOfResultArrayMethod + vectorizationForbidden:true +- type: repo-file +- spec: handsontable/hyperformula:src/interpreter/plugin/SequencePlugin.ts@feature/HF-69-sort +- verify: grep -E "sizeOfResultArrayMethod|vectorizationForbidden" /workspaces/hyperformula-worktrees/HF-69-sort/src/interpreter/plugin/SequencePlugin.ts +- expect: vectorizationForbidden +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_4] FILTER is the dynamic-size exemplar: parse-time size method returns an ArraySize from input, runtime returns fewer rows +- type: repo-file +- spec: handsontable/hyperformula:src/interpreter/plugin/ArrayPlugin.ts@feature/HF-69-sort +- verify: grep -E "filterArraySize|new ArraySize\(width, height\)" /workspaces/hyperformula-worktrees/HF-69-sort/src/interpreter/plugin/ArrayPlugin.ts +- expect: filterArraySize +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_5] ArithmeticHelper exposes lt/gt/eq comparators handling mixed types, empties, and collation +- type: repo-file +- spec: handsontable/hyperformula:src/interpreter/ArithmeticHelper.ts@feature/HF-69-sort +- verify: grep -E "public (lt|gt|eq) =" /workspaces/hyperformula-worktrees/HF-69-sort/src/interpreter/ArithmeticHelper.ts +- expect: public lt = +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_6] Plugins auto-register: every non-underscore export from interpreter/plugin/index.ts is registered as a built-in +- type: repo-file +- spec: handsontable/hyperformula:src/index.ts@feature/HF-69-sort +- verify: grep -E "registerFunctionPlugin\(plugins\[pluginName\]\)" /workspaces/hyperformula-worktrees/HF-69-sort/src/index.ts +- expect: registerFunctionPlugin +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_7] SORT/UNIQUE are greenfield: no SortPlugin or UniquePlugin class exists in src +- type: command +- spec: repository source tree @feature/HF-69-sort +- verify: grep -rlE "class SortPlugin|class UniquePlugin" /workspaces/hyperformula-worktrees/HF-69-sort/src/ || echo NONE +- expect: NONE +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[dec_1] Two separate PRs, one per ClickUp task, each off clean develop +- type: transcript +- spec: HF-68/HF-69 handoff (Delivery) — Kuba tracks HF-68 and HF-69 separately — https://app.clickup.com/t/86c89q1tt , https://app.clickup.com/t/86c89q1tq +- verify: echo "Manual — handoff Delivery line: two separate PRs, not combined" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[dec_2] sort_order strictly {1,-1}; any other value yields #VALUE! +- type: transcript +- spec: this ADR §Decisions; documented contract per vrf_1; undocumented quirk deferred per con_1 +- verify: echo "Manual — strict documented contract chosen; sort_order=0 quirk unverifiable, deferred" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[dec_3] Reuse ArithmeticHelper.lt/gt for SORT ordering and eq for UNIQUE equality (stable sort for ties) +- type: transcript +- spec: this ADR §Decisions; comparator exists per vrf_5 +- verify: echo "Manual — reuse existing comparator; no bespoke ordering" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[dec_4] Error-type map: non-finite/negative-zero-sort_index/over-limit -> #VALUE!; error arg propagates; wrong arity -> #N/A +- type: transcript +- spec: HF-68/HF-69 handoff (Stage 2 error-type map) + this ADR §Decisions +- verify: echo "Manual — error map per handoff Stage 2" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[dec_5] UNIQUE equality uses eq (case-insensitive by default, config-driven), matching Excel's case-insensitive UNIQUE +- type: transcript +- spec: this ADR §Decisions; eq semantics per vrf_5 +- verify: echo "Manual — UNIQUE case-insensitive by default via eq, honors caseSensitive config" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[dec_6] Single-key sort_index in v1; multi-key array-constant sort_index deferred; error messages LessThanOne / ValueLarge / BadMode +- type: transcript +- spec: this ADR §Decisions; sort_index typed NUMBER; multi-key needs a different arg shape +- verify: echo "Manual — v1 single-key sort_index; multi-key deferred to known-limitations" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[dec_7] SORT and UNIQUE propagate the first CellError found in the input range +- type: transcript +- spec: this ADR §Decisions; lt/gt/eq operate on no-error scalars; extends dec_4 to range contents +- verify: echo "Manual — propagate first in-range error; Excel in-array error behavior unverifiable" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[dec_8] Empty UNIQUE result (exactly_once with no once-only row/col) -> #NA (EmptyRange), mirroring FILTER +- type: transcript +- spec: this ADR §Decisions; Excel returns #CALC! (no HF equivalent); mirror FILTER empty-result mapping +- verify: echo "Manual — empty UNIQUE -> NA/EmptyRange like FILTER" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[con_1] No live Excel in this environment; MS docs are the oracle; undocumented quirks (sort_order=0) flagged for live-Excel/Kuba, not asserted from memory +- type: transcript +- spec: this ADR §Context (Verification constraint) +- verify: echo "Manual — no live Excel; documented behavior only; quirks deferred to live-Excel verification" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[con_2] Dual test env (jest + karma/Jasmine): no it.each / toHaveLength / arrayContaining / fs; specs must compile with tsconfig.test.json +- type: transcript +- spec: HF-68/HF-69 handoff (Stage 3 dual test env) + reference_dual_test_env_jasmine +- verify: echo "Manual — dual-env spec constraints per handoff Stage 3" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[con_3] SORT output shape = input shape (deterministic size); UNIQUE dynamic size mirrors FILTER (input size as upper bound) +- type: transcript +- spec: this ADR §Decisions; FILTER pattern per vrf_4 +- verify: echo "Manual — SORT deterministic size; UNIQUE dynamic, mirrors FILTER" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index d486c8e04..a322bca16 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -67,6 +67,7 @@ Total number of functions: **{{ $page.functionsCount }}** | SEQUENCE | Returns an array of sequential numbers. | SEQUENCE(Rows, [Cols], [Start], [Step]) | | VSTACK | Stacks arrays vertically into a single array. | VSTACK(Array1, [Array2], ...[ArrayN]) | | HSTACK | Stacks arrays horizontally into a single array. | HSTACK(Array1, [Array2], ...[ArrayN]) | +| SORT | Sorts the rows or columns of an array. | SORT(Array, [SortIndex], [SortOrder], [ByCol]) | ### Date and time diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index c4a1bbc61..76a56c3d7 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,6 +38,14 @@ a circular reference. * 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. * The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback. +### SORT function + +* The `SortIndex` argument accepts a single key only. Multi-key sorting through an array constant (for example `=SORT(A1:B9, {1,2})`) is not supported; sort by one column or row at a time. + +* The `SortOrder` argument must be exactly `1` (ascending) or `-1` (descending). Any other value returns a `#VALUE!` error. + +* Ordering (including mixed types, empty cells, and text collation) follows HyperFormula's own comparison rules, which honor the `caseSensitive` and `accentSensitive` configuration options. Numbers sort before text, and text before logical values. + ### OFFSET function HyperFormula resolves the OFFSET function at parse time rather than during evaluation. The parser inspects the arguments and rewrites the expression into a plain cell reference or range. This keeps the dependency graph accurate but imposes several restrictions. diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index ff9649112..9e035a881 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'ODPIS.LIN', + SORT: 'SORT', SPLIT: 'SPLIT', SQRT: 'ODMOCNINA', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 6a44e95ce..6a9e78f7d 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LA', + SORT: 'SORTER', SPLIT: 'SPLIT', SQRT: 'KVROD', STDEVA: 'STDAFVV', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 7633eb14f..1becb9679 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINHYP', SLN: 'LIA', + SORT: 'SORTIEREN', SPLIT: 'SPLIT', SQRT: 'WURZEL', STDEVA: 'STABWA', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 7011a8162..0350eb314 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -210,6 +210,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'SLN', + SORT: 'SORT', SPLIT: 'SPLIT', SQRT: 'SQRT', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 4d3a8217f..f446ad063 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -208,6 +208,7 @@ export const dictionary: RawTranslationPackage = { SIN: 'SENO', SINH: 'SENOH', SLN: 'SLN', + SORT: 'ORDENAR', SPLIT: 'SPLIT', SQRT: 'RAIZ', STDEVA: 'DESVESTA', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 55f13bd03..554b12e33 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'STP', + SORT: 'LAJITTELE', SPLIT: 'SPLIT', SQRT: 'NELIÖJUURI', STDEVA: 'KESKIHAJONTAA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index de69b12ec..81bcdd779 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'AMORLIN', + SORT: 'TRIER', SPLIT: 'SPLIT', SQRT: 'RACINE', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 200787a5e..4d429523a 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LCSA', + SORT: 'SORBA.RENDEZ', SPLIT: 'SPLIT', SQRT: 'GYÖK', STDEVA: 'SZÓRÁSA', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 72e69bb05..e5c8723cb 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -210,6 +210,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'GSL', + SORT: 'SORT', SPLIT: 'PISAH', SQRT: 'AKAR', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 93a6f9a69..210047149 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SEN', SINH: 'SENH', SLN: 'AMMORT.COST', + SORT: 'DATI.ORDINA', SPLIT: 'SPLIT', SQRT: 'RADQ', STDEVA: 'DEV.ST.VALORI', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index a0c9b9e47..fcfa50c8f 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LINAVS', + SORT: 'SORTER', SPLIT: 'SPLIT', SQRT: 'ROT', STDEVA: 'STDAVVIKA', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index f8c1eed96..fcc0640c4 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LIN.AFSCHR', + SORT: 'SORTEREN', SPLIT: 'SPLIT', SQRT: 'WORTEL', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index b6b8aa5ec..8b42176d3 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'SLN', + SORT: 'SORTUJ', SPLIT: 'PODZIEL.TEKST', SQRT: 'PIERWIASTEK', STDEVA: 'ODCH.STANDARDOWE.A', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 3d9386d9f..1d478543a 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SEN', SINH: 'SENH', SLN: 'DPD', + SORT: 'ORDENAR', SPLIT: 'SPLIT', SQRT: 'RAIZ', STDEVA: 'DESVPADA', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index c247b3050..856d94083 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'АПЛ', + SORT: 'СОРТ', SPLIT: 'SPLIT', SQRT: 'КОРЕНЬ', STDEVA: 'СТАНДОТКЛОНА', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 13fae5523..ad27924ee 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LINAVSKR', + SORT: 'SORTERA', SPLIT: 'SPLIT', SQRT: 'ROT', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 8196edc45..c19796ed6 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SİN', SINH: 'SINH', SLN: 'DA', + SORT: 'SIRALA', SPLIT: 'SPLIT', SQRT: 'KAREKÖK', STDEVA: 'STDSAPMAA', diff --git a/src/interpreter/plugin/SortPlugin.ts b/src/interpreter/plugin/SortPlugin.ts new file mode 100644 index 000000000..8924fa02a --- /dev/null +++ b/src/interpreter/plugin/SortPlugin.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {ArraySize} from '../../ArraySize' +import {CellError, ErrorType} from '../../Cell' +import {ErrorMessage} from '../../error-message' +import {ProcedureAst} from '../../parser' +import {InterpreterState} from '../InterpreterState' +import {InternalNoErrorScalarValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' +import {SimpleRangeValue} from '../../SimpleRangeValue' +import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' + +/** + * Plugin implementing the SORT spreadsheet function. + * + * SORT(array, [sort_index], [sort_order], [by_col]) returns the elements of + * `array` sorted along one dimension. The returned array has the same shape as + * the input. By default it reorders rows using the first column as the key, + * ascending. Ordering is delegated to {@link ArithmeticHelper} so that mixed + * types, empty cells, and locale collation behave exactly as elsewhere in the + * engine. + */ +export class SortPlugin extends FunctionPlugin implements FunctionPluginTypecheck { + public static implementedFunctions: ImplementedFunctions = { + 'SORT': { + method: 'sort', + sizeOfResultArrayMethod: 'sortArraySize', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.BOOLEAN, defaultValue: false, emptyAsDefault: true}, + ], + vectorizationForbidden: true, + }, + } + + /** + * Corresponds to SORT(array, [sort_index], [sort_order], [by_col]). + * + * `sort_order` is validated strictly to {1, -1} (the documented Excel contract); + * any other value yields #VALUE!. `sort_index` is a 1-based index into the sort + * dimension (columns when sorting rows, rows when `by_col` is TRUE). Errors found + * anywhere in the input range are propagated. + * + * @param {ProcedureAst} ast - the parsed function-call AST node. + * @param {InterpreterState} state - current interpreter evaluation state. + */ + public sort(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('SORT'), + (range: SimpleRangeValue, sortIndex: number, sortOrder: number, byCol: boolean) => { + if (sortOrder !== 1 && sortOrder !== -1) { + return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) + } + if (!Number.isFinite(sortIndex)) { + return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge) + } + + const data = range.data + const height = range.height() + const width = range.width() + + const firstError = SortPlugin.findFirstError(data) + if (firstError !== undefined) { + return firstError + } + + const index = Math.trunc(sortIndex) + const sortDimension = byCol ? height : width + if (index < 1) { + return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne) + } + if (index > sortDimension) { + return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge) + } + + const keyIndex = index - 1 + const compare = (a: InternalNoErrorScalarValue, b: InternalNoErrorScalarValue): number => { + if (this.arithmeticHelper.lt(a, b)) { + return -sortOrder + } + if (this.arithmeticHelper.gt(a, b)) { + return sortOrder + } + return 0 + } + + if (byCol) { + // Reorder columns; the key of column c is data[keyIndex][c]. + const order = Array.from({length: width}, (_, c) => c) + order.sort((c1, c2) => compare( + data[keyIndex][c1] as InternalNoErrorScalarValue, + data[keyIndex][c2] as InternalNoErrorScalarValue, + )) + const result: InternalScalarValue[][] = data.map(row => order.map(c => row[c])) + return SimpleRangeValue.onlyValues(result) + } + + // Reorder rows; the key of row r is data[r][keyIndex]. + const rows: InternalScalarValue[][] = data.map(row => row.slice()) + rows.sort((r1, r2) => compare( + r1[keyIndex] as InternalNoErrorScalarValue, + r2[keyIndex] as InternalNoErrorScalarValue, + )) + return SimpleRangeValue.onlyValues(rows) + } + ) + } + + /** + * Predicts the output array size for SORT at parse time. + * The result is always the same shape as the input array. + * + * @param {ProcedureAst} ast - the parsed function-call AST node. + * @param {InterpreterState} state - current interpreter evaluation state. + */ + public sortArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 1 || ast.args.length > 4) { + return ArraySize.error() + } + const metadata = this.metadata('SORT') + const subChecks = ast.args.map((arg) => + this.arraySizeForAst(arg, new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)))) + // Return a fresh ArraySize (isRef defaults to false). Propagating the input's + // ArraySize verbatim would carry its `isRef` flag, and ArraySize.isScalar() + // treats a ref as scalar — which would collapse the spilled result into a + // single cell. SORT's output always matches the input shape. + return new ArraySize(subChecks[0].width, subChecks[0].height) + } + + /** Returns the first {@link CellError} found in a 2-D array, or undefined. */ + private static findFirstError(data: InternalScalarValue[][]): CellError | undefined { + for (const row of data) { + for (const cell of row) { + if (cell instanceof CellError) { + return cell + } + } + } + return undefined + } +} diff --git a/src/interpreter/plugin/index.ts b/src/interpreter/plugin/index.ts index e86ba4f2a..a390ad843 100644 --- a/src/interpreter/plugin/index.ts +++ b/src/interpreter/plugin/index.ts @@ -36,6 +36,7 @@ export {RadiansPlugin} from './RadiansPlugin' export {RadixConversionPlugin} from './RadixConversionPlugin' export {RandomPlugin} from './RandomPlugin' export {SequencePlugin} from './SequencePlugin' +export {SortPlugin} from './SortPlugin' export {RoundingPlugin} from './RoundingPlugin' export {SqrtPlugin} from './SqrtPlugin' export {ConditionalAggregationPlugin} from './ConditionalAggregationPlugin' From 95019e49e1257d020f1ed23a713c6afa727cc2dd Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 21 Jul 2026 10:20:56 +0000 Subject: [PATCH 2/9] fix(sort): return #N/A for an empty input range instead of throwing (HF-69) A whole-column reference to an empty sheet resolves to a zero-row range. The row/column sort paths then handed an empty 2-D array to SimpleRangeValue.onlyValues, which reads data[0].length and threw an uncaught TypeError. Guard the empty case up front and return #N/A (Empty range not allowed), matching UNIQUE and FILTER. Co-Authored-By: Claude Opus 4.8 --- src/interpreter/plugin/SortPlugin.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/interpreter/plugin/SortPlugin.ts b/src/interpreter/plugin/SortPlugin.ts index 8924fa02a..b0685cbec 100644 --- a/src/interpreter/plugin/SortPlugin.ts +++ b/src/interpreter/plugin/SortPlugin.ts @@ -67,6 +67,14 @@ export class SortPlugin extends FunctionPlugin implements FunctionPluginTypechec return firstError } + // An empty input range (e.g. a whole-column reference to an empty sheet) + // yields no rows/columns to sort. Return #N/A rather than letting the empty + // 2-D array reach SimpleRangeValue.onlyValues, which reads data[0].length and + // would throw. Mirrors UNIQUE/FILTER's empty-range handling. + if (data.length === 0 || data[0].length === 0) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyRange) + } + const index = Math.trunc(sortIndex) const sortDimension = byCol ? height : width if (index < 1) { From 010aa8a27c4b2cbf4fa3ff682eb5aaa8c868b04f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 21 Jul 2026 10:28:20 +0000 Subject: [PATCH 3/9] fix(sort): enable array arithmetic for arguments (HF-69) Without enableArrayArithmeticForArguments, an array expression passed as the first argument (e.g. =SORT(A1:A3*2)) is coerced as a scalar and only the first cell is computed. Matches FILTER/VSTACK/HSTACK so computed arrays can be sorted without an ARRAYFORMULA wrapper. Co-Authored-By: Claude Opus 4.8 --- src/interpreter/plugin/SortPlugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/interpreter/plugin/SortPlugin.ts b/src/interpreter/plugin/SortPlugin.ts index b0685cbec..250fba7f6 100644 --- a/src/interpreter/plugin/SortPlugin.ts +++ b/src/interpreter/plugin/SortPlugin.ts @@ -27,6 +27,7 @@ export class SortPlugin extends FunctionPlugin implements FunctionPluginTypechec 'SORT': { method: 'sort', sizeOfResultArrayMethod: 'sortArraySize', + enableArrayArithmeticForArguments: true, parameters: [ {argumentType: FunctionArgumentType.RANGE}, {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1, emptyAsDefault: true}, From f2f43fe1a8ebc2066d85762c2c39ac786e2a8ce1 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 21 Jul 2026 21:21:40 +0000 Subject: [PATCH 4/9] fix(sort): keep empty cells last, matching Excel (HF-69) Excel's SORT places blank cells at the end of the result in both ascending and descending order, rather than coercing them to 0 and ordering by value. Force empties last (direction-independent) in the comparator before delegating to ArithmeticHelper. Co-Authored-By: Claude Opus 4.8 --- src/interpreter/plugin/SortPlugin.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/interpreter/plugin/SortPlugin.ts b/src/interpreter/plugin/SortPlugin.ts index 250fba7f6..a6ce1c929 100644 --- a/src/interpreter/plugin/SortPlugin.ts +++ b/src/interpreter/plugin/SortPlugin.ts @@ -8,7 +8,7 @@ import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' import {ProcedureAst} from '../../parser' import {InterpreterState} from '../InterpreterState' -import {InternalNoErrorScalarValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' +import {EmptyValue, InternalNoErrorScalarValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' import {SimpleRangeValue} from '../../SimpleRangeValue' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' @@ -87,6 +87,18 @@ export class SortPlugin extends FunctionPlugin implements FunctionPluginTypechec const keyIndex = index - 1 const compare = (a: InternalNoErrorScalarValue, b: InternalNoErrorScalarValue): number => { + // Excel keeps empty cells at the end of the result regardless of + // sort_order, rather than coercing them to 0 and ordering by value. + // Force empties last (direction-independent) before delegating the + // rest to ArithmeticHelper. + const aEmpty = a === EmptyValue + const bEmpty = b === EmptyValue + if (aEmpty || bEmpty) { + if (aEmpty && bEmpty) { + return 0 + } + return aEmpty ? 1 : -1 + } if (this.arithmeticHelper.lt(a, b)) { return -sortOrder } From 2f7d81665b9eb45f270134e3383f606d32de056e Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 21 Jul 2026 21:29:31 +0000 Subject: [PATCH 5/9] ci: re-trigger after paired tests update (HF-69) From 0e08973ae5285daa2ee40293307f5da08d98a036 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 22 Jul 2026 05:09:50 +0000 Subject: [PATCH 6/9] refactor(sort): drop unreachable non-finite sort_index guard (HF-69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HF converts overflow/NaN to #NUM! during argument evaluation, so a non-finite sort_index never reaches the function body — the guard was dead code. Out-of-range indices are still handled by the <1 / >dimension checks. Removes the last uncovered lines in SortPlugin. Co-Authored-By: Claude Opus 4.8 --- src/interpreter/plugin/SortPlugin.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/interpreter/plugin/SortPlugin.ts b/src/interpreter/plugin/SortPlugin.ts index a6ce1c929..936d4314d 100644 --- a/src/interpreter/plugin/SortPlugin.ts +++ b/src/interpreter/plugin/SortPlugin.ts @@ -55,9 +55,6 @@ export class SortPlugin extends FunctionPlugin implements FunctionPluginTypechec if (sortOrder !== 1 && sortOrder !== -1) { return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) } - if (!Number.isFinite(sortIndex)) { - return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge) - } const data = range.data const height = range.height() From 36d3bdf52684f8ebb783800d585a28b97ab82584 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 22 Jul 2026 06:11:57 +0000 Subject: [PATCH 7/9] ci: re-trigger coverage upload after dead-code removal (HF-69-sort) From b86ca5bad300a493d880ba44a1f7d0857b489066 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 23 Jul 2026 08:49:26 +0000 Subject: [PATCH 8/9] docs: move SORT (HF-69) ADR to hyperformula-tests dev_docs Per review on #1707: keep the ADR but relocate it out of the engine repo into hyperformula-tests/dev_docs, since a per-function implementation ADR belongs with the test suite rather than the shipped docs dir. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-13-sort-unique-array-functions.md | 262 ------------------ 1 file changed, 262 deletions(-) delete mode 100644 docs/adr/2026-07-13-sort-unique-array-functions.md diff --git a/docs/adr/2026-07-13-sort-unique-array-functions.md b/docs/adr/2026-07-13-sort-unique-array-functions.md deleted file mode 100644 index f5bdcb218..000000000 --- a/docs/adr/2026-07-13-sort-unique-array-functions.md +++ /dev/null @@ -1,262 +0,0 @@ -# ADR 2026-07-13 — SORT (HF-69) + UNIQUE (HF-68) dynamic array functions - -- **status**: accepted -- **date**: 2026-07-13 -- **tasks**: HF-69 (86c89q1tt) — SORT · HF-68 (86c89q1tq) — UNIQUE -- **parent**: HF-28 (86c6r57ke) — Modern dynamic array functions · **siblings**: SEQUENCE (shipped), VSTACK/HSTACK (HF-71, in review) -- **base branch**: clean `develop` (`00e5c730a`) — two independent feature branches [dec_1] - -## Context - -HF-28 tracks the modern dynamic-array family. SEQUENCE is shipped and is the anatomy exemplar: -a plugin declares `implementedFunctions` with `method`, a mandatory `sizeOfResultArrayMethod`, -and `vectorizationForbidden: true`; the runtime method returns a `SimpleRangeValue`/`CellError` -via `runFunction`, and the parse-time method returns an `ArraySize` [vrf_3]. FILTER is the -exemplar for a **dynamic-size** result: it predicts the input array size at parse time and -returns fewer rows at runtime, tolerating the size mismatch [vrf_4]. SORT and UNIQUE are -greenfield — no `SortPlugin`/`UniquePlugin` exists in `src/` [vrf_7]. - -Excel's documented contracts (authoritative, Microsoft Learn): - -- **SORT(array, [sort_index], [sort_order], [by_col])** — returns an array the **same shape** as - `array`. `sort_index` is a 1-based row/column number (default row1/col1); `sort_order` is `1` - ascending (default) or `-1` descending; `by_col` is `FALSE` (default, sort by row) or `TRUE` - (sort by column) [vrf_1]. -- **UNIQUE(array, [by_col], [exactly_once])** — `by_col` `FALSE` (default) compares rows and - returns unique rows, `TRUE` compares columns and returns unique columns; `exactly_once` `TRUE` - returns only rows/columns occurring exactly once, `FALSE` (default) returns all distinct - rows/columns. Result size is data-dependent (dynamic) [vrf_2]. - -HyperFormula already has an ordering comparator: `ArithmeticHelper` exposes `lt`/`gt`/`eq`, which -handle mixed types (numbers < strings < booleans via `CellValueTypeOrd`), empty cells, and -locale collation driven by `caseSensitive`/`accentSensitive` config [vrf_5]. Plugins register -automatically — every non-`_` export from `src/interpreter/plugin/index.ts` is registered as a -built-in [vrf_6] — so wiring is: add the plugin file, export it, and add the function name to all -17 i18n language packs. - -**Verification constraint.** This environment has no live Excel. Microsoft Learn documentation is -the oracle for documented behavior [vrf_1][vrf_2]. Behaviors **not** in the docs (notably the -reported "`sort_order=0` does not error" quirk) cannot be re-verified here and are **not** asserted -from memory — we adopt the strict documented contract and flag the quirk for live-Excel/Kuba -confirmation [con_1]. - -## Decisions - -- **Two separate PRs, one per ClickUp task, off clean `develop`** [dec_1]: Kuba tracks HF-68 and - HF-69 separately. SORT on `feature/HF-69-sort`, UNIQUE on `feature/HF-68-unique`, each with its - own paired `hyperformula-tests` PR. The shared ADR + plan are committed to both branches. - -- **`sort_order` strictly `{1, -1}`; any other value → `#VALUE!`** [dec_2]: Microsoft documents - only `1` and `-1` [vrf_1]. The reported `sort_order=0` non-error is undocumented and could not - be verified against live Excel here [con_1]; we choose the strict documented contract (simpler, - testable without Excel, defensible) and will revisit if live-Excel/Kuba confirms otherwise. - -- **Reuse `ArithmeticHelper.lt`/`gt` for SORT ordering and `eq` for UNIQUE equality** [dec_3]: no - new comparator. SORT's per-key comparator is `lt/gt` scaled by `sort_order`, with a **stable** - sort so ties preserve input order (matches Excel's stable behavior). UNIQUE dedups by comparing - rows/columns element-wise with `eq`, which is case-insensitive by default (Excel UNIQUE is - case-insensitive) and honors HF's collation config [dec_5]. - -- **Error-type map** [dec_4]: - - non-finite (`Infinity`/`NaN`) numeric arg → `#VALUE!` - - `sort_index` < 1 or exceeding the sort dimension → `#VALUE!` - - `sort_order` ∉ `{1,-1}` → `#VALUE!` (per [dec_2]) - - result exceeding `maxRows`/`maxColumns` → `#VALUE!` - - any error-typed argument → propagate automatically via `runFunction` - - wrong arity → `#N/A` automatically (arg-count validation) - -- **Single-key `sort_index` in v1; multi-key array-constant `sort_index` (e.g. `{1,2}`) deferred** - [dec_6]: the `sort_index` parameter is typed `NUMBER`; accepting an array constant would require - a different argument shape. v1 sorts by one key; multi-key is recorded in - `known-limitations.md` and left for a follow-up. Concrete error messages: `sort_index < 1` → - `LessThanOne`; `sort_index >` the sort dimension → `ValueLarge`; `sort_order ∉ {1,-1}` → - `BadMode`. - -- **Propagate the first error found in the input range** [dec_7]: `lt`/`gt`/`eq` operate on - no-error scalars, and Excel's exact in-array error behavior is unverifiable here [con_1]. SORT and - UNIQUE scan the input and return the first `CellError` encountered, consistent with the - "error arg → propagate" rule extended to range contents [dec_4]. - -- **Empty UNIQUE result → `#NA` (`EmptyRange`)** [dec_8]: only reachable via `exactly_once` when no - row/column occurs exactly once. Excel returns `#CALC!`, which HyperFormula has no type for; we - mirror FILTER's empty-result mapping (`ErrorType.NA` / `ErrorMessage.EmptyRange`) for consistency - with the sibling dynamic-size function. - -- **Result-size prediction** [con_3]: SORT output shape equals input shape → parse-time size = - `arraySizeForAst(array)` (deterministic). UNIQUE output size is data-dependent → mirror FILTER: - predict the input size as an upper bound and return the smaller actual result at runtime - [vrf_4]. - -## Consequences - -- **Positive**: both functions reuse existing, tested infrastructure (comparator, `runFunction`, - array-size machinery); behavior for mixed types/empties/collation is consistent with the rest of - HF automatically; auto-registration means minimal wiring. -- **Neutral / follow-ups**: the strict `sort_order` contract [dec_2] and any other Excel divergence - are surfaced in three places (PR Notes, inline comment, test name/comment) and recorded in - `docs/guide/known-limitations.md` / `list-of-differences.md` where behavior diverges. The - `sort_order=0` quirk is an open live-Excel verification item, not a shipped guarantee [con_1]. -- **Negative**: without live Excel, tie-breaking on mixed types and the exact collation of UNIQUE - are validated against HF's own comparator semantics + MS docs, not a byte-for-byte Excel oracle; - xlsx oracle fixtures are provided so a human/graph-runner with Excel can confirm later. - -## Alternatives considered - -1. **One combined PR for both functions** — rejected: Kuba tracks HF-68 and HF-69 as separate - ClickUp tasks; combined delivery breaks his tracking [dec_1]. -2. **Write a bespoke SORT comparator** — rejected: `ArithmeticHelper.lt/gt/eq` already encodes - Excel-consistent cross-type ordering, empties, and collation [vrf_5]; a bespoke comparator would - drift from the rest of the engine [dec_3]. -3. **Treat `sort_order=0` as valid ascending** (matching the reported Excel quirk) — rejected: the - quirk is undocumented and unverifiable in this environment [con_1]; asserting it from memory - would violate feature-traceability. Strict documented contract chosen [dec_2]. -4. **Predict a fixed 1×1 size for UNIQUE** — rejected: FILTER's input-size-upper-bound prediction - is the established dynamic-size pattern and spills correctly [vrf_4][con_3]. -5. **Make UNIQUE case-sensitive** — rejected: Excel UNIQUE is case-insensitive and HF's `eq` - default (`caseSensitive: false`) matches it while staying config-driven [dec_5]. - -## §AuditSources - -[vrf_1] Microsoft documents SORT(array,[sort_index],[sort_order],[by_col]); result same shape as array; sort_order 1=ascending(default)/-1=descending; by_col FALSE(default)=by row -- type: live-http -- spec: https://support.microsoft.com/en-us/office/sort-function-22f63bd0-ccc8-492f-953d-c20e8e44b86c -- verify: curl -sSL -o /dev/null -w "%{http_code}" "https://support.microsoft.com/en-us/office/sort-function-22f63bd0-ccc8-492f-953d-c20e8e44b86c" -- expect: ^200$ -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[vrf_2] Microsoft documents UNIQUE(array,[by_col],[exactly_once]); by_col FALSE=unique rows; exactly_once TRUE=rows/cols occurring exactly once; dynamic result size -- type: live-http -- spec: https://support.microsoft.com/en-us/office/unique-function-c5ab87fd-30a3-4ce9-9d1a-40204fb85e1e -- verify: curl -sSL -o /dev/null -w "%{http_code}" "https://support.microsoft.com/en-us/office/unique-function-c5ab87fd-30a3-4ce9-9d1a-40204fb85e1e" -- expect: ^200$ -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[vrf_3] SEQUENCE plugin is the anatomy exemplar: sizeOfResultArrayMethod + vectorizationForbidden:true -- type: repo-file -- spec: handsontable/hyperformula:src/interpreter/plugin/SequencePlugin.ts@feature/HF-69-sort -- verify: grep -E "sizeOfResultArrayMethod|vectorizationForbidden" /workspaces/hyperformula-worktrees/HF-69-sort/src/interpreter/plugin/SequencePlugin.ts -- expect: vectorizationForbidden -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[vrf_4] FILTER is the dynamic-size exemplar: parse-time size method returns an ArraySize from input, runtime returns fewer rows -- type: repo-file -- spec: handsontable/hyperformula:src/interpreter/plugin/ArrayPlugin.ts@feature/HF-69-sort -- verify: grep -E "filterArraySize|new ArraySize\(width, height\)" /workspaces/hyperformula-worktrees/HF-69-sort/src/interpreter/plugin/ArrayPlugin.ts -- expect: filterArraySize -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[vrf_5] ArithmeticHelper exposes lt/gt/eq comparators handling mixed types, empties, and collation -- type: repo-file -- spec: handsontable/hyperformula:src/interpreter/ArithmeticHelper.ts@feature/HF-69-sort -- verify: grep -E "public (lt|gt|eq) =" /workspaces/hyperformula-worktrees/HF-69-sort/src/interpreter/ArithmeticHelper.ts -- expect: public lt = -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[vrf_6] Plugins auto-register: every non-underscore export from interpreter/plugin/index.ts is registered as a built-in -- type: repo-file -- spec: handsontable/hyperformula:src/index.ts@feature/HF-69-sort -- verify: grep -E "registerFunctionPlugin\(plugins\[pluginName\]\)" /workspaces/hyperformula-worktrees/HF-69-sort/src/index.ts -- expect: registerFunctionPlugin -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[vrf_7] SORT/UNIQUE are greenfield: no SortPlugin or UniquePlugin class exists in src -- type: command -- spec: repository source tree @feature/HF-69-sort -- verify: grep -rlE "class SortPlugin|class UniquePlugin" /workspaces/hyperformula-worktrees/HF-69-sort/src/ || echo NONE -- expect: NONE -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[dec_1] Two separate PRs, one per ClickUp task, each off clean develop -- type: transcript -- spec: HF-68/HF-69 handoff (Delivery) — Kuba tracks HF-68 and HF-69 separately — https://app.clickup.com/t/86c89q1tt , https://app.clickup.com/t/86c89q1tq -- verify: echo "Manual — handoff Delivery line: two separate PRs, not combined" -- expect: . -- asserted-by: marcin-kordas-hoc -- asserted-at: 2026-07-13T12:30:00Z - -[dec_2] sort_order strictly {1,-1}; any other value yields #VALUE! -- type: transcript -- spec: this ADR §Decisions; documented contract per vrf_1; undocumented quirk deferred per con_1 -- verify: echo "Manual — strict documented contract chosen; sort_order=0 quirk unverifiable, deferred" -- expect: . -- asserted-by: marcin-kordas-hoc -- asserted-at: 2026-07-13T12:30:00Z - -[dec_3] Reuse ArithmeticHelper.lt/gt for SORT ordering and eq for UNIQUE equality (stable sort for ties) -- type: transcript -- spec: this ADR §Decisions; comparator exists per vrf_5 -- verify: echo "Manual — reuse existing comparator; no bespoke ordering" -- expect: . -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[dec_4] Error-type map: non-finite/negative-zero-sort_index/over-limit -> #VALUE!; error arg propagates; wrong arity -> #N/A -- type: transcript -- spec: HF-68/HF-69 handoff (Stage 2 error-type map) + this ADR §Decisions -- verify: echo "Manual — error map per handoff Stage 2" -- expect: . -- asserted-by: marcin-kordas-hoc -- asserted-at: 2026-07-13T12:30:00Z - -[dec_5] UNIQUE equality uses eq (case-insensitive by default, config-driven), matching Excel's case-insensitive UNIQUE -- type: transcript -- spec: this ADR §Decisions; eq semantics per vrf_5 -- verify: echo "Manual — UNIQUE case-insensitive by default via eq, honors caseSensitive config" -- expect: . -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[dec_6] Single-key sort_index in v1; multi-key array-constant sort_index deferred; error messages LessThanOne / ValueLarge / BadMode -- type: transcript -- spec: this ADR §Decisions; sort_index typed NUMBER; multi-key needs a different arg shape -- verify: echo "Manual — v1 single-key sort_index; multi-key deferred to known-limitations" -- expect: . -- asserted-by: marcin-kordas-hoc -- asserted-at: 2026-07-13T12:30:00Z - -[dec_7] SORT and UNIQUE propagate the first CellError found in the input range -- type: transcript -- spec: this ADR §Decisions; lt/gt/eq operate on no-error scalars; extends dec_4 to range contents -- verify: echo "Manual — propagate first in-range error; Excel in-array error behavior unverifiable" -- expect: . -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[dec_8] Empty UNIQUE result (exactly_once with no once-only row/col) -> #NA (EmptyRange), mirroring FILTER -- type: transcript -- spec: this ADR §Decisions; Excel returns #CALC! (no HF equivalent); mirror FILTER empty-result mapping -- verify: echo "Manual — empty UNIQUE -> NA/EmptyRange like FILTER" -- expect: . -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[con_1] No live Excel in this environment; MS docs are the oracle; undocumented quirks (sort_order=0) flagged for live-Excel/Kuba, not asserted from memory -- type: transcript -- spec: this ADR §Context (Verification constraint) -- verify: echo "Manual — no live Excel; documented behavior only; quirks deferred to live-Excel verification" -- expect: . -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z - -[con_2] Dual test env (jest + karma/Jasmine): no it.each / toHaveLength / arrayContaining / fs; specs must compile with tsconfig.test.json -- type: transcript -- spec: HF-68/HF-69 handoff (Stage 3 dual test env) + reference_dual_test_env_jasmine -- verify: echo "Manual — dual-env spec constraints per handoff Stage 3" -- expect: . -- asserted-by: marcin-kordas-hoc -- asserted-at: 2026-07-13T12:30:00Z - -[con_3] SORT output shape = input shape (deterministic size); UNIQUE dynamic size mirrors FILTER (input size as upper bound) -- type: transcript -- spec: this ADR §Decisions; FILTER pattern per vrf_4 -- verify: echo "Manual — SORT deterministic size; UNIQUE dynamic, mirrors FILTER" -- expect: . -- asserted-by: claude-opus-4-8 -- asserted-at: 2026-07-13T12:30:00Z From f081be57e9651d13d7228fa33eea3a75ab4aaa28 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 23 Jul 2026 10:30:00 +0000 Subject: [PATCH 9/9] docs(sort): drop direct Excel reference from JSDoc Describe the sort_order contract on its own terms instead of naming Excel in a published surface (JSDoc feeds the API docs). Internal implementation comments that reference Excel as the behavioural oracle are kept. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/interpreter/plugin/SortPlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interpreter/plugin/SortPlugin.ts b/src/interpreter/plugin/SortPlugin.ts index 936d4314d..088bd275e 100644 --- a/src/interpreter/plugin/SortPlugin.ts +++ b/src/interpreter/plugin/SortPlugin.ts @@ -41,7 +41,7 @@ export class SortPlugin extends FunctionPlugin implements FunctionPluginTypechec /** * Corresponds to SORT(array, [sort_index], [sort_order], [by_col]). * - * `sort_order` is validated strictly to {1, -1} (the documented Excel contract); + * `sort_order` is validated strictly to {1, -1} (the documented contract); * any other value yields #VALUE!. `sort_index` is a 1-based index into the sort * dimension (columns when sorting rows, rows when `by_col` is TRUE). Errors found * anywhere in the input range are propagated.