From 1ba9daa263d1e8e870e4fe2276cafefe7a5b000d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 23:18:10 -0400 Subject: [PATCH] =?UTF-8?q?M0:=20foundation=20=E2=80=94=20core,=20numfmt,?= =?UTF-8?q?=20formula,=20functions=20(478),=20model,=20calc,=20xlsx/csv,?= =?UTF-8?q?=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean-room spreadsheet engine: addresses/values/dates, Excel number formats, formula parser/printer/reference adjustment, worksheet function library, copy-on-write workbook model, dependency-driven recalculation with dynamic arrays, XLSX/CSV I/O, and a command engine (cell entry, clipboard, fill, formatting, sheets, sort/filter, tables, charts, CF, names, view, file). Co-Authored-By: Claude Opus 5.5 --- .gitignore | 9 +- AGENTS.md | 53 + ATTRIBUTION.md | 19 + CLAUDE.md | 53 + Cargo.lock | 367 ++++ Cargo.toml | 65 + LICENSE-APACHE | 177 ++ LICENSE-MIT | 21 + NOTICE | 16 + README.md | 22 +- clippy.toml | 4 + crates/calc/Cargo.toml | 17 + crates/calc/src/eval.rs | 1382 ++++++++++++++ crates/calc/src/lib.rs | 13 + crates/calc/src/recalc.rs | 491 +++++ crates/calc/src/tests.rs | 213 +++ crates/core/Cargo.toml | 16 + crates/core/src/addr.rs | 280 +++ crates/core/src/date.rs | 184 ++ crates/core/src/lib.rs | 13 + crates/core/src/parse.rs | 358 ++++ crates/core/src/value.rs | 421 +++++ crates/engine/Cargo.toml | 22 + crates/engine/src/catalog.rs | 326 ++++ crates/engine/src/cf.rs | 279 +++ crates/engine/src/cmd/data.rs | 909 ++++++++++ crates/engine/src/cmd/edit.rs | 1224 +++++++++++++ crates/engine/src/cmd/file.rs | 141 ++ crates/engine/src/cmd/format.rs | 903 ++++++++++ crates/engine/src/cmd/formulas.rs | 396 ++++ crates/engine/src/cmd/insert.rs | 920 ++++++++++ crates/engine/src/cmd/inspect.rs | 161 ++ crates/engine/src/cmd/mod.rs | 230 +++ crates/engine/src/cmd/review.rs | 91 + crates/engine/src/cmd/sheet.rs | 507 ++++++ crates/engine/src/cmd/view.rs | 263 +++ crates/engine/src/display.rs | 136 ++ crates/engine/src/fill.rs | 504 ++++++ crates/engine/src/io.rs | 237 +++ crates/engine/src/lib.rs | 311 ++++ crates/engine/src/sample.rs | 188 ++ crates/engine/src/selection.rs | 237 +++ crates/engine/src/tables.rs | 148 ++ crates/engine/src/tests.rs | 175 ++ crates/formula/Cargo.toml | 14 + crates/formula/src/adjust.rs | 198 ++ crates/formula/src/ast.rs | 229 +++ crates/formula/src/lexer.rs | 312 ++++ crates/formula/src/lib.rs | 17 + crates/formula/src/parser.rs | 450 +++++ crates/formula/src/printer.rs | 224 +++ crates/formula/src/tests.rs | 142 ++ crates/functions/Cargo.toml | 13 + crates/functions/src/criteria.rs | 314 ++++ crates/functions/src/database.rs | 374 ++++ crates/functions/src/date.rs | 759 ++++++++ crates/functions/src/dynamic.rs | 1228 +++++++++++++ crates/functions/src/engineering.rs | 1341 ++++++++++++++ crates/functions/src/financial.rs | 1609 +++++++++++++++++ crates/functions/src/info.rs | 605 +++++++ crates/functions/src/lib.rs | 279 +++ crates/functions/src/lift.rs | 97 + crates/functions/src/lookup.rs | 690 +++++++ crates/functions/src/math.rs | 1587 ++++++++++++++++ crates/functions/src/special.rs | 391 ++++ crates/functions/src/stat.rs | 2583 +++++++++++++++++++++++++++ crates/functions/src/text.rs | 1043 +++++++++++ crates/functions/src/util.rs | 430 +++++ crates/model/Cargo.toml | 18 + crates/model/src/b64.rs | 61 + crates/model/src/cell.rs | 74 + crates/model/src/features.rs | 478 +++++ crates/model/src/lib.rs | 25 + crates/model/src/sheet.rs | 261 +++ crates/model/src/store.rs | 231 +++ crates/model/src/style.rs | 437 +++++ crates/model/src/workbook.rs | 189 ++ crates/numfmt/Cargo.toml | 12 + crates/numfmt/src/builtin.rs | 56 + crates/numfmt/src/decimal.rs | 181 ++ crates/numfmt/src/lib.rs | 321 ++++ crates/numfmt/src/parse.rs | 639 +++++++ crates/numfmt/src/render.rs | 479 +++++ crates/numfmt/src/tests.rs | 423 +++++ crates/xlsx/Cargo.toml | 17 + crates/xlsx/src/chart.rs | 586 ++++++ crates/xlsx/src/csv.rs | 210 +++ crates/xlsx/src/drawing.rs | 360 ++++ crates/xlsx/src/fmla.rs | 55 + crates/xlsx/src/lib.rs | 116 ++ crates/xlsx/src/package.rs | 189 ++ crates/xlsx/src/read.rs | 321 ++++ crates/xlsx/src/sheet_read.rs | 850 +++++++++ crates/xlsx/src/sheet_write.rs | 989 ++++++++++ crates/xlsx/src/styles.rs | 709 ++++++++ crates/xlsx/src/tables.rs | 332 ++++ crates/xlsx/src/tests/csv_tests.rs | 120 ++ crates/xlsx/src/tests/fixtures.rs | 339 ++++ crates/xlsx/src/tests/malformed.rs | 123 ++ crates/xlsx/src/tests/mod.rs | 46 + crates/xlsx/src/tests/roundtrip.rs | 565 ++++++ crates/xlsx/src/theme.rs | 128 ++ crates/xlsx/src/write.rs | 333 ++++ crates/xlsx/src/xml.rs | 413 +++++ docs/brand/LICENSE-brand.txt | 42 + docs/brand/artcraft-logo-white.png | Bin 0 -> 32161 bytes docs/brand/artcraft-logo-white.svg | 7 + docs/brand/artcraft-logo.png | Bin 0 -> 31302 bytes docs/brand/artcraft-logo.svg | 7 + docs/brand/artcraft-mark-black.png | Bin 0 -> 26793 bytes docs/brand/artcraft-mark-black.svg | 4 + docs/brand/artcraft-mark.png | Bin 0 -> 24715 bytes docs/brand/artcraft-mark.svg | 4 + rustfmt.toml | 2 + 114 files changed, 38179 insertions(+), 4 deletions(-) create mode 100644 AGENTS.md create mode 100644 ATTRIBUTION.md create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 NOTICE create mode 100644 clippy.toml create mode 100644 crates/calc/Cargo.toml create mode 100644 crates/calc/src/eval.rs create mode 100644 crates/calc/src/lib.rs create mode 100644 crates/calc/src/recalc.rs create mode 100644 crates/calc/src/tests.rs create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/addr.rs create mode 100644 crates/core/src/date.rs create mode 100644 crates/core/src/lib.rs create mode 100644 crates/core/src/parse.rs create mode 100644 crates/core/src/value.rs create mode 100644 crates/engine/Cargo.toml create mode 100644 crates/engine/src/catalog.rs create mode 100644 crates/engine/src/cf.rs create mode 100644 crates/engine/src/cmd/data.rs create mode 100644 crates/engine/src/cmd/edit.rs create mode 100644 crates/engine/src/cmd/file.rs create mode 100644 crates/engine/src/cmd/format.rs create mode 100644 crates/engine/src/cmd/formulas.rs create mode 100644 crates/engine/src/cmd/insert.rs create mode 100644 crates/engine/src/cmd/inspect.rs create mode 100644 crates/engine/src/cmd/mod.rs create mode 100644 crates/engine/src/cmd/review.rs create mode 100644 crates/engine/src/cmd/sheet.rs create mode 100644 crates/engine/src/cmd/view.rs create mode 100644 crates/engine/src/display.rs create mode 100644 crates/engine/src/fill.rs create mode 100644 crates/engine/src/io.rs create mode 100644 crates/engine/src/lib.rs create mode 100644 crates/engine/src/sample.rs create mode 100644 crates/engine/src/selection.rs create mode 100644 crates/engine/src/tables.rs create mode 100644 crates/engine/src/tests.rs create mode 100644 crates/formula/Cargo.toml create mode 100644 crates/formula/src/adjust.rs create mode 100644 crates/formula/src/ast.rs create mode 100644 crates/formula/src/lexer.rs create mode 100644 crates/formula/src/lib.rs create mode 100644 crates/formula/src/parser.rs create mode 100644 crates/formula/src/printer.rs create mode 100644 crates/formula/src/tests.rs create mode 100644 crates/functions/Cargo.toml create mode 100644 crates/functions/src/criteria.rs create mode 100644 crates/functions/src/database.rs create mode 100644 crates/functions/src/date.rs create mode 100644 crates/functions/src/dynamic.rs create mode 100644 crates/functions/src/engineering.rs create mode 100644 crates/functions/src/financial.rs create mode 100644 crates/functions/src/info.rs create mode 100644 crates/functions/src/lib.rs create mode 100644 crates/functions/src/lift.rs create mode 100644 crates/functions/src/lookup.rs create mode 100644 crates/functions/src/math.rs create mode 100644 crates/functions/src/special.rs create mode 100644 crates/functions/src/stat.rs create mode 100644 crates/functions/src/text.rs create mode 100644 crates/functions/src/util.rs create mode 100644 crates/model/Cargo.toml create mode 100644 crates/model/src/b64.rs create mode 100644 crates/model/src/cell.rs create mode 100644 crates/model/src/features.rs create mode 100644 crates/model/src/lib.rs create mode 100644 crates/model/src/sheet.rs create mode 100644 crates/model/src/store.rs create mode 100644 crates/model/src/style.rs create mode 100644 crates/model/src/workbook.rs create mode 100644 crates/numfmt/Cargo.toml create mode 100644 crates/numfmt/src/builtin.rs create mode 100644 crates/numfmt/src/decimal.rs create mode 100644 crates/numfmt/src/lib.rs create mode 100644 crates/numfmt/src/parse.rs create mode 100644 crates/numfmt/src/render.rs create mode 100644 crates/numfmt/src/tests.rs create mode 100644 crates/xlsx/Cargo.toml create mode 100644 crates/xlsx/src/chart.rs create mode 100644 crates/xlsx/src/csv.rs create mode 100644 crates/xlsx/src/drawing.rs create mode 100644 crates/xlsx/src/fmla.rs create mode 100644 crates/xlsx/src/lib.rs create mode 100644 crates/xlsx/src/package.rs create mode 100644 crates/xlsx/src/read.rs create mode 100644 crates/xlsx/src/sheet_read.rs create mode 100644 crates/xlsx/src/sheet_write.rs create mode 100644 crates/xlsx/src/styles.rs create mode 100644 crates/xlsx/src/tables.rs create mode 100644 crates/xlsx/src/tests/csv_tests.rs create mode 100644 crates/xlsx/src/tests/fixtures.rs create mode 100644 crates/xlsx/src/tests/malformed.rs create mode 100644 crates/xlsx/src/tests/mod.rs create mode 100644 crates/xlsx/src/tests/roundtrip.rs create mode 100644 crates/xlsx/src/theme.rs create mode 100644 crates/xlsx/src/write.rs create mode 100644 crates/xlsx/src/xml.rs create mode 100644 docs/brand/LICENSE-brand.txt create mode 100644 docs/brand/artcraft-logo-white.png create mode 100644 docs/brand/artcraft-logo-white.svg create mode 100644 docs/brand/artcraft-logo.png create mode 100644 docs/brand/artcraft-logo.svg create mode 100644 docs/brand/artcraft-mark-black.png create mode 100644 docs/brand/artcraft-mark-black.svg create mode 100644 docs/brand/artcraft-mark.png create mode 100644 docs/brand/artcraft-mark.svg create mode 100644 rustfmt.toml diff --git a/.gitignore b/.gitignore index 0807ae54..ab320279 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ *~ plan/ -plans/ +/target +/dist +/corpus +/log +.DS_Store +/.claude/ +/target-*/ +/craft-fonts/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b67b18e5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# SheetCraft — instructions for agents + +SheetCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../printcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects). + +Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. + +## Start every session here +1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app). +2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. + +`plan/` is gitignored (local only). + +## Assets and iconography — absolute rule +**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure. +- Every asset in the repo is original work by SheetCraft contributors, or public domain / CC0, or open source / Creative Commons that allows redistribution (or licensed open source by the contributor who created it). +- Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md) (author, source, licence). `cargo xtask assets` enforces it. Adding an asset without a row is a bug. +- Prefer art generated in code: the UI icon set (`crates/ui-egui/src/icons.rs`), table and cell style palettes, themes and sample workbooks are drawn/defined in code and original. +- Never copy Office's icons, ribbon artwork, theme XML, table-style definitions, templates, clip art or wording beyond feature names. Excel feature *names* (menu labels, function names) are fine. +- Screenshots of Microsoft software are never committed (they live only in `plan/excel/screenshots/`). +- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception. + +## Never crash +People trust SheetCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). +- **Errors are `Result`** through the crate's error type and `?`. +- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). +- **Bound recursion** (formula nesting, dependency chains, names referring to names). +- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command). +- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. + +## Non-negotiables +- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`. +- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code. +- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them. +- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). +- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** +- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. +- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`). + +## Running and looking at the app +- `cargo run --release -p sheetcraft -- --sample sales --control 7979` (sample workbook + control channel). +- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. +- **Offscreen UI render** (no window): `cargo run --release -p sheetcraft-cli -- snapshot --sample sales out.png`. +- Headless: `sheetcraft-cli eval '=SUM(1,2,3)'`, `sheetcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `sheetcraft-cli mcp`. +- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. +- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`. +- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done. + +## Roadmap +`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` regenerates `docs/parity.md` from `crates/engine/src/catalog.rs`. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 00000000..3a539037 --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,19 @@ +# Asset attribution + +Every non-code asset in this repository (images, icons, fonts, example files, presets) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table. + +**Policy (mandatory):** SheetCraft contains **no Microsoft, Adobe, Avid or Autodesk iconography, images, artwork, fonts, templates, themes, table styles or sample files**. Every asset is original work by SheetCraft contributors or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution). Screenshots of Microsoft software are never committed. Font files are not added here: shared fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (`CRAFT_FONTS_DIR`). The one exception is the ArtCraft brand files in `docs/brand/`: ArtCraft trademarks, not open source, used under `docs/brand/LICENSE-brand.txt`. + +Generated-in-code art is original and has no file to list: the UI icon set (`crates/ui-egui/src/icons.rs`), themes (`crates/engine/src/cmd/view.rs`), cell styles (`crates/engine/src/cmd/format.rs`), table styles (`crates/engine/src/tables.rs`), chart palettes and the sample workbooks (`crates/engine/src/sample.rs`). + +| Asset | Author | Source | Licence | Notes | +|---|---|---|---|---| +| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | +| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b67b18e5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# SheetCraft — instructions for agents + +SheetCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../printcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects). + +Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. + +## Start every session here +1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app). +2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. + +`plan/` is gitignored (local only). + +## Assets and iconography — absolute rule +**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure. +- Every asset in the repo is original work by SheetCraft contributors, or public domain / CC0, or open source / Creative Commons that allows redistribution (or licensed open source by the contributor who created it). +- Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md) (author, source, licence). `cargo xtask assets` enforces it. Adding an asset without a row is a bug. +- Prefer art generated in code: the UI icon set (`crates/ui-egui/src/icons.rs`), table and cell style palettes, themes and sample workbooks are drawn/defined in code and original. +- Never copy Office's icons, ribbon artwork, theme XML, table-style definitions, templates, clip art or wording beyond feature names. Excel feature *names* (menu labels, function names) are fine. +- Screenshots of Microsoft software are never committed (they live only in `plan/excel/screenshots/`). +- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception. + +## Never crash +People trust SheetCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). +- **Errors are `Result`** through the crate's error type and `?`. +- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). +- **Bound recursion** (formula nesting, dependency chains, names referring to names). +- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command). +- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. + +## Non-negotiables +- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`. +- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code. +- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them. +- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). +- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** +- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. +- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`). + +## Running and looking at the app +- `cargo run --release -p sheetcraft -- --sample sales --control 7979` (sample workbook + control channel). +- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. +- **Offscreen UI render** (no window): `cargo run --release -p sheetcraft-cli -- snapshot --sample sales out.png`. +- Headless: `sheetcraft-cli eval '=SUM(1,2,3)'`, `sheetcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `sheetcraft-cli mcp`. +- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. +- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`. +- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done. + +## Roadmap +`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` regenerates `docs/parity.md` from `crates/engine/src/catalog.rs`. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..0b5c7d67 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,367 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sheetcraft-calc" +version = "0.1.0" +dependencies = [ + "log", + "sheetcraft-core", + "sheetcraft-formula", + "sheetcraft-functions", + "sheetcraft-model", + "sheetcraft-numfmt", +] + +[[package]] +name = "sheetcraft-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "sheetcraft-engine" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", + "sheetcraft-calc", + "sheetcraft-core", + "sheetcraft-formula", + "sheetcraft-functions", + "sheetcraft-model", + "sheetcraft-numfmt", + "sheetcraft-xlsx", + "thiserror", +] + +[[package]] +name = "sheetcraft-formula" +version = "0.1.0" +dependencies = [ + "serde", + "sheetcraft-core", + "thiserror", +] + +[[package]] +name = "sheetcraft-functions" +version = "0.1.0" +dependencies = [ + "regex", + "sheetcraft-core", +] + +[[package]] +name = "sheetcraft-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sheetcraft-core", + "sheetcraft-formula", + "thiserror", +] + +[[package]] +name = "sheetcraft-numfmt" +version = "0.1.0" +dependencies = [ + "sheetcraft-core", +] + +[[package]] +name = "sheetcraft-xlsx" +version = "0.1.0" +dependencies = [ + "quick-xml", + "sheetcraft-core", + "sheetcraft-formula", + "sheetcraft-model", + "thiserror", + "zip", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..7cd0d650 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,65 @@ +[workspace] +resolver = "3" +members = ["crates/*"] +# default-members restored once apps exist + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.90" +repository = "https://github.com/storytold/sheetcraft" + +[workspace.dependencies] +# internal +sheetcraft-core = { path = "crates/core" } +sheetcraft-numfmt = { path = "crates/numfmt" } +sheetcraft-formula = { path = "crates/formula" } +sheetcraft-functions = { path = "crates/functions" } +sheetcraft-model = { path = "crates/model" } +sheetcraft-calc = { path = "crates/calc" } +sheetcraft-xlsx = { path = "crates/xlsx" } +sheetcraft-chart = { path = "crates/chart" } +sheetcraft-engine = { path = "crates/engine" } +sheetcraft-ui-egui = { path = "crates/ui-egui" } +sheetcraft-mcp = { path = "crates/mcp" } + +# foundation +thiserror = "2" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = { version = "1", features = ["float_roundtrip"] } +log = "0.4" +rayon = "1" +regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } +quick-xml = "0.38" +zip = { version = "3", default-features = false, features = ["deflate"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } + +# ui +egui = "0.36" +egui_extras = { version = "0.36", default-features = false } +eframe = { version = "0.36", default-features = false } +egui_kittest = "0.36" +rfd = "0.17" +arboard = { version = "3.6", default-features = false } + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 16 +debug = 0 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..71b54378 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArtCraft Team and the SheetCraft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..1d9ddbe8 --- /dev/null +++ b/NOTICE @@ -0,0 +1,16 @@ +SheetCraft +Copyright (c) 2026 ArtCraft Team and the SheetCraft contributors. +Licensed under the MIT License or the Apache License, Version 2.0, at your option +(LICENSE-MIT, LICENSE-APACHE). + +The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are +not open source and are not covered by the license above; their terms are in +docs/brand/LICENSE-brand.txt. + +Third-party material + +This product bundles third-party material under its own licenses, listed below. The per-file +list (author, source, license) is in ATTRIBUTION.md. + +None: every bundled asset is original to SheetCraft. Builds made with craft-fonts +(CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1. diff --git a/README.md b/README.md index 01efaddd..5651357c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,20 @@ -SheetCraft -=========== +

+ + + + ArtCraft + + +

-By ArtCraft +

SheetCraft

+ +

+ Spreadsheets and data; an open-source, clean-room reimplementation of Microsoft Excel, rebuilt in pure Rust. +

+ +

+ Join the ArtCraft community on Discord +

+ +Work in progress — the full README with screenshots lands with the first alpha. diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..4b6c79f5 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/crates/calc/Cargo.toml b/crates/calc/Cargo.toml new file mode 100644 index 00000000..94791c4d --- /dev/null +++ b/crates/calc/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sheetcraft-calc" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft calculation engine: dependency graph, recalculation, evaluator, dynamic arrays" + +[lints] +workspace = true + +[dependencies] +sheetcraft-core = { workspace = true } +sheetcraft-formula = { workspace = true } +sheetcraft-functions = { workspace = true } +sheetcraft-numfmt = { workspace = true } +sheetcraft-model = { workspace = true } +log = { workspace = true } diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs new file mode 100644 index 00000000..c32d16d9 --- /dev/null +++ b/crates/calc/src/eval.rs @@ -0,0 +1,1382 @@ +//! The formula evaluator. +//! +//! Expressions evaluate to [`Ev`]: a value, a reference (one or more areas, kept unresolved so +//! `ROW`, `OFFSET`, `INDEX`, `:` and friends can work on references) or a lambda. Cell reads go +//! through [`Host`], which lets the recalculation engine evaluate dirty precedents on demand. + +use std::sync::Arc; + +use sheetcraft_core::{Array, CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, compare}; +use sheetcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp}; +use sheetcraft_functions::{Arg, Ctx}; +use sheetcraft_model::Workbook; + +/// Largest array a reference may expand to. +pub const MAX_CELLS: u64 = 16_000_000; +const MAX_DEPTH: usize = 400; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Area { + pub sheet: usize, + pub range: RangeRef, +} + +#[derive(Clone, Debug)] +pub struct Lambda { + pub params: Vec, + pub body: Expr, + pub env: Vec<(String, Ev)>, +} + +#[derive(Clone, Debug)] +pub enum Ev { + V(Value), + R(Vec), + L(Arc), +} + +impl From for Ev { + fn from(v: Value) -> Self { + Ev::V(v) + } +} + +fn err(e: CellError) -> Ev { + Ev::V(Value::Error(e)) +} + +/// What the evaluator needs from its surroundings. +pub trait Host { + fn workbook(&self) -> &Workbook; + /// The current value of a cell (evaluating it first if it is dirty). + fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value; + fn now_serial(&self) -> f64; + fn random(&mut self) -> f64; + /// Called when a volatile or dynamic-reference function is used. + fn note_volatile(&mut self) {} +} + +pub struct Evaluator<'h> { + pub host: &'h mut dyn Host, + pub sheet: usize, + pub at: CellRef, + env: Vec<(String, Ev)>, + depth: usize, +} + +struct FnCtx<'a, 'h> { + ev: &'a mut Evaluator<'h>, +} + +impl Ctx for FnCtx<'_, '_> { + fn date_system(&self) -> sheetcraft_core::DateSystem { + self.ev.host.workbook().date_system + } + fn now_serial(&self) -> f64 { + self.ev.host.now_serial() + } + fn random(&mut self) -> f64 { + self.ev.host.random() + } +} + +impl<'h> Evaluator<'h> { + pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self { + Evaluator { host, sheet, at, env: Vec::new(), depth: 0 } + } + + /// Evaluates to a value (references are dereferenced; multi-cell → array). + pub fn value(&mut self, e: &Expr) -> Value { + let ev = self.eval(e); + self.deref(ev) + } + + fn resolve_sheet(&self, s: &SheetSel) -> Result, CellError> { + let wb = self.host.workbook(); + match s { + SheetSel::Current => Ok(vec![self.sheet]), + SheetSel::Named(n) => wb.sheet_index(n).map(|i| vec![i]).ok_or(CellError::Ref), + SheetSel::Span(a, b) => { + let (i, j) = (wb.sheet_index(a).ok_or(CellError::Ref)?, wb.sheet_index(b).ok_or(CellError::Ref)?); + Ok((i.min(j)..=i.max(j)).collect()) + } + } + } + + pub fn reference_areas(&self, r: &Reference) -> Result, CellError> { + let range = r.range(); + Ok(self.resolve_sheet(&r.sheet)?.into_iter().map(|sheet| Area { sheet, range }).collect()) + } + + /// Dereferences: one cell → its value; a block → an array (trimmed to the used range); + /// several areas → `#VALUE!`. + pub fn deref(&mut self, ev: Ev) -> Value { + match ev { + Ev::V(v) => v, + Ev::L(_) => Value::Error(CellError::Calc), + Ev::R(areas) => match areas.as_slice() { + [a] => self.area_value(a), + _ => Value::Error(CellError::Value), + }, + } + } + + pub fn area_value(&mut self, a: &Area) -> Value { + if a.range.is_single() { + return self.host.cell_value(a.sheet, a.range.start); + } + let range = self.trim(a); + let (h, w) = (range.height() as usize, range.width() as usize); + if (h as u64) * (w as u64) > MAX_CELLS { + return Value::Error(CellError::Num); + } + let mut data = Vec::with_capacity(h * w); + for c in range.iter() { + data.push(self.host.cell_value(a.sheet, c)); + } + Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)) + } + + /// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so + /// positions stay right). + pub fn trim(&self, a: &Area) -> RangeRef { + let Some(sheet) = self.host.workbook().sheet(a.sheet) else { return a.range }; + let used = sheet.used_range(); + let (mut r1, mut c1) = (a.range.end.row, a.range.end.col); + match used { + Some(u) => { + r1 = r1.min(u.end.row.max(a.range.start.row)); + c1 = c1.min(u.end.col.max(a.range.start.col)); + } + None => { + r1 = a.range.start.row; + c1 = a.range.start.col; + } + } + RangeRef::new(a.range.start, CellRef::new(r1, c1)) + } + + pub fn eval(&mut self, e: &Expr) -> Ev { + self.depth += 1; + if self.depth > MAX_DEPTH { + self.depth -= 1; + return err(CellError::Calc); + } + let r = self.eval_inner(e); + self.depth -= 1; + r + } + + fn eval_inner(&mut self, e: &Expr) -> Ev { + match e { + Expr::Number(n) => Ev::V(Value::number(*n)), + Expr::Text(t) => Ev::V(Value::Text(t.clone())), + Expr::Bool(b) => Ev::V(Value::Bool(*b)), + Expr::Error(x) => err(*x), + Expr::Missing => Ev::V(Value::Empty), + Expr::Paren(x) => self.eval(x), + Expr::Array(rows) => { + let h = rows.len(); + let w = rows.first().map_or(0, Vec::len); + let mut data = Vec::with_capacity(h * w); + for row in rows { + for el in row { + let v = self.value(el); + data.push(v); + } + } + Ev::V(Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + Expr::Ref(r) => match self.reference_areas(r) { + Ok(a) => Ev::R(a), + Err(x) => err(x), + }, + Expr::Name(n) => self.name(n), + Expr::Struct(s) => self.structured(s), + Expr::Unary(op, x) => self.unary(*op, x), + Expr::Binary(op, a, b) => self.binary(*op, a, b), + Expr::Call(name, args) => self.call(name, args), + Expr::Invoke(callee, args) => { + let f = self.eval(callee); + match f { + Ev::L(l) => self.apply_lambda(&l, args), + Ev::V(Value::Error(x)) => err(x), + _ => err(CellError::Value), + } + } + } + } + + fn name(&mut self, n: &str) -> Ev { + if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)) { + return v.clone(); + } + let wb = self.host.workbook(); + // Sheet-qualified `Sheet1!Name`. + let (scope_sheet, bare) = match n.split_once('!') { + Some((s, b)) => match wb.sheet_index(s.trim_matches('\'')) { + Some(i) => (i, b), + None => return err(CellError::Name), + }, + None => (self.sheet, n), + }; + if let Some(def) = wb.name(bare, scope_sheet) { + let text = def.formula.clone(); + let scope = def.scope.unwrap_or(self.sheet); + return match sheetcraft_formula::parse(&text) { + Ok(expr) => { + let saved = self.sheet; + // Unqualified references in a name refer to the scope sheet. + self.sheet = scope; + let r = self.eval(&expr); + self.sheet = saved; + r + } + Err(_) => err(CellError::Name), + }; + } + // A table name alone means its data body. + if let Some((si, ti)) = wb.table(bare) + && let Some(t) = wb.sheet(si).and_then(|s| s.tables.get(ti)) + && let Some(d) = t.data_range() + { + return Ev::R(vec![Area { sheet: si, range: d }]); + } + err(CellError::Name) + } + + fn structured(&mut self, s: &StructRef) -> Ev { + let wb = self.host.workbook(); + let found = if s.table.is_empty() { + wb.sheet(self.sheet).and_then(|sh| sh.tables.iter().position(|t| t.range.contains(self.at)).map(|ti| (self.sheet, ti))) + } else { + wb.table(&s.table) + }; + let Some((si, ti)) = found else { return err(CellError::Ref) }; + let Some(t) = wb.sheet(si).and_then(|sh| sh.tables.get(ti)) else { return err(CellError::Ref) }; + let mut r0 = t.range.start.row; + let mut r1 = t.range.end.row; + let data = t.data_range(); + let items = &s.specifiers; + if items.is_empty() || items == &[StructItem::Data] { + match data { + Some(d) => { + r0 = d.start.row; + r1 = d.end.row; + } + None => return err(CellError::Ref), + } + } else if items.contains(&StructItem::ThisRow) { + if self.at.row < t.range.start.row || self.at.row > t.range.end.row { + return err(CellError::Value); + } + r0 = self.at.row; + r1 = self.at.row; + } else if !items.contains(&StructItem::All) { + let has = |i: StructItem| items.contains(&i); + let hdr = t.header_row.then_some(t.range.start.row); + let tot = t.totals_row.then_some(t.range.end.row); + let mut lo = u32::MAX; + let mut hi = 0; + let mut add = |a: u32, b: u32| { + lo = lo.min(a); + hi = hi.max(b); + }; + if has(StructItem::Headers) { + match hdr { + Some(h) => add(h, h), + None => return err(CellError::Ref), + } + } + if has(StructItem::Data) + && let Some(d) = data + { + add(d.start.row, d.end.row); + } + if has(StructItem::Totals) { + match tot { + Some(h) => add(h, h), + None => return err(CellError::Ref), + } + } + if lo > hi { + return err(CellError::Ref); + } + r0 = lo; + r1 = hi; + } + let (mut c0, mut c1) = (t.range.start.col, t.range.end.col); + if let Some(a) = &s.col_start { + let Some(i) = t.column_index(a) else { return err(CellError::Ref) }; + c0 = t.range.start.col + i as u32; + c1 = c0; + if let Some(b) = &s.col_end { + let Some(j) = t.column_index(b) else { return err(CellError::Ref) }; + let cj = t.range.start.col + j as u32; + c0 = c0.min(cj); + c1 = c1.max(cj); + } + } + Ev::R(vec![Area { sheet: si, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) + } + + fn unary(&mut self, op: UnOp, x: &Expr) -> Ev { + if op == UnOp::At { + let ev = self.eval(x); + return match ev { + Ev::R(areas) => match areas.as_slice() { + [a] => match self.implicit_intersect(a) { + Some(c) => Ev::V(self.host.cell_value(a.sheet, c)), + None => err(CellError::Value), + }, + _ => err(CellError::Value), + }, + Ev::V(v) => Ev::V(v.scalar()), + l => l, + }; + } + let v = self.value(x); + Ev::V(map_unary(&v, &|n| match op { + UnOp::Neg => Value::number(-n), + UnOp::Plus => Value::number(n), + UnOp::Percent => Value::number(n / 100.0), + UnOp::At => Value::number(n), + }, op == UnOp::Plus)) + } + + fn implicit_intersect(&self, a: &Area) -> Option { + let r = a.range; + if r.is_single() { + return Some(r.start); + } + if r.width() == 1 && r.start.row <= self.at.row && self.at.row <= r.end.row { + return Some(CellRef::new(self.at.row, r.start.col)); + } + if r.height() == 1 && r.start.col <= self.at.col && self.at.col <= r.end.col { + return Some(CellRef::new(r.start.row, self.at.col)); + } + None + } + + fn binary(&mut self, op: BinOp, a: &Expr, b: &Expr) -> Ev { + match op { + BinOp::Range => { + let (x, y) = (self.eval(a), self.eval(b)); + let (Ev::R(xa), Ev::R(ya)) = (&x, &y) else { + if let Ev::V(Value::Error(e)) = x { + return err(e); + } + if let Ev::V(Value::Error(e)) = y { + return err(e); + } + return err(CellError::Value); + }; + let all: Vec<&Area> = xa.iter().chain(ya.iter()).collect(); + let Some(first) = all.first() else { return err(CellError::Ref) }; + if all.iter().any(|ar| ar.sheet != first.sheet) { + return err(CellError::Value); + } + let mut r = first.range; + for ar in &all { + r = r.union(&ar.range); + } + Ev::R(vec![Area { sheet: first.sheet, range: r }]) + } + BinOp::Union => { + let (x, y) = (self.eval(a), self.eval(b)); + match (x, y) { + (Ev::R(mut xa), Ev::R(ya)) => { + xa.extend(ya); + Ev::R(xa) + } + (Ev::V(Value::Error(e)), _) | (_, Ev::V(Value::Error(e))) => err(e), + _ => err(CellError::Value), + } + } + BinOp::Intersect => { + let (x, y) = (self.eval(a), self.eval(b)); + match (x, y) { + (Ev::R(xa), Ev::R(ya)) => { + let mut out = Vec::new(); + for p in &xa { + for q in &ya { + if p.sheet == q.sheet + && let Some(i) = p.range.intersection(&q.range) + { + out.push(Area { sheet: p.sheet, range: i }); + } + } + } + if out.is_empty() { err(CellError::Null) } else { Ev::R(out) } + } + (Ev::V(Value::Error(e)), _) | (_, Ev::V(Value::Error(e))) => err(e), + _ => err(CellError::Value), + } + } + _ => { + let x = self.value(a); + let y = self.value(b); + Ev::V(binary_values(op, &x, &y)) + } + } + } + + // ------------------------------------------------------------------ calls + + fn arg_ev(&mut self, args: &[Expr], i: usize) -> Option { + args.get(i).map(|e| self.eval(e)) + } + fn arg_val(&mut self, args: &[Expr], i: usize) -> Option { + args.get(i).map(|e| self.value(e)) + } + fn arg_num(&mut self, args: &[Expr], i: usize, default: f64) -> Result { + match args.get(i) { + None | Some(Expr::Missing) => Ok(default), + Some(e) => { + let v = self.value(e); + v.scalar().to_number() + } + } + } + + fn call(&mut self, name: &str, args: &[Expr]) -> Ev { + match name { + "IF" => { + if args.is_empty() || args.len() > 3 { + return err(CellError::Value); + } + let c = self.arg_val(args, 0).unwrap_or_default(); + if let Value::Array(arr) = &c { + // Array condition: element-wise. + let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); + let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; + return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() { + Ok(true) => t.clone(), + Ok(false) => f.clone(), + Err(e) => Value::Error(e), + })); + } + match c.to_bool() { + Ok(true) => match args.get(1) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(e) => self.eval(e), + None => Ev::V(Value::Bool(true)), + }, + Ok(false) => match args.get(2) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(e) => self.eval(e), + None => Ev::V(Value::Bool(false)), + }, + Err(e) => err(e), + } + } + "IFS" => { + if args.len() < 2 || !args.len().is_multiple_of(2) { + return err(CellError::Value); + } + for pair in args.chunks(2) { + let [c, v] = pair else { break }; + let cv = self.value(c); + match cv.scalar().to_bool() { + Ok(true) => return self.eval(v), + Ok(false) => {} + Err(e) => return err(e), + } + } + err(CellError::NA) + } + "IFERROR" | "IFNA" => { + if args.len() != 2 { + return err(CellError::Value); + } + let v = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); + let na_only = name == "IFNA"; + let catch = |e: CellError| if na_only { e == CellError::NA } else { true }; + match &v { + Ev::V(Value::Error(e)) if catch(*e) => self.eval(&args[1]), + Ev::V(Value::Array(a)) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { + let alt = self.arg_val(args, 1).unwrap_or_default(); + let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); + Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + Ev::R(_) => { + let val = self.deref(v.clone()); + match val { + Value::Error(e) if catch(e) => self.eval(&args[1]), + Value::Array(ref a) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { + let alt = self.arg_val(args, 1).unwrap_or_default(); + let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); + Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + _ => v, + } + } + _ => v, + } + } + "CHOOSE" => { + if args.len() < 2 { + return err(CellError::Value); + } + let i = match self.arg_num(args, 0, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + if i < 1 || i as usize >= args.len() { + return err(CellError::Value); + } + self.eval(&args[i as usize]) + } + "SWITCH" => { + if args.len() < 3 { + return err(CellError::Value); + } + let v = self.arg_val(args, 0).unwrap_or_default().scalar(); + if let Value::Error(e) = v { + return err(e); + } + let rest = &args[1..]; + let mut i = 0; + while i + 1 < rest.len() { + let cand = self.value(&rest[i]).scalar(); + if !cand.is_error() && compare(&v, &cand) == std::cmp::Ordering::Equal && same_kind(&v, &cand) { + return self.eval(&rest[i + 1]); + } + i += 2; + } + if rest.len() % 2 == 1 { rest.last().map_or(err(CellError::NA), |d| self.eval(d)) } else { err(CellError::NA) } + } + "ROW" | "COLUMN" => { + let is_row = name == "ROW"; + if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) { + return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64)); + } + match self.eval(&args[0]) { + Ev::R(a) => { + let Some(a) = a.first() else { return err(CellError::Ref) }; + let r = self.trim_lines(a); + if is_row { + let rows: Vec = (r.start.row..=r.end.row).map(|x| Value::number(x as f64 + 1.0)).collect(); + if rows.len() == 1 { Ev::V(rows.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(rows).into()) } + } else { + let cols: Vec = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect(); + if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) } + } + } + Ev::V(Value::Error(e)) => err(e), + _ => err(CellError::Value), + } + } + "ROWS" | "COLUMNS" => { + if args.len() != 1 { + return err(CellError::Value); + } + let is_rows = name == "ROWS"; + match self.eval(&args[0]) { + Ev::R(a) => match a.as_slice() { + [a] => Ev::V(Value::number(if is_rows { a.range.height() } else { a.range.width() } as f64)), + _ => err(CellError::Ref), + }, + Ev::V(Value::Array(arr)) => Ev::V(Value::number(if is_rows { arr.rows } else { arr.cols } as f64)), + Ev::V(Value::Error(e)) => err(e), + Ev::V(_) => Ev::V(Value::Number(1.0)), + Ev::L(_) => err(CellError::Value), + } + } + "AREAS" => match args.first().map(|a| self.eval(a)) { + Some(Ev::R(a)) => Ev::V(Value::number(a.len() as f64)), + Some(Ev::V(Value::Error(e))) => err(e), + _ => err(CellError::Value), + }, + "ISREF" => Ev::V(Value::Bool(matches!(args.first().map(|a| self.eval(a)), Some(Ev::R(_))))), + "ISFORMULA" | "FORMULATEXT" => { + let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) }; + let Some(a) = a.first() else { return err(CellError::NA) }; + let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone()); + if name == "ISFORMULA" { + Ev::V(Value::Bool(f.is_some())) + } else { + match f { + Some(f) => Ev::V(Value::text(format!("={}", f.text))), + None => err(CellError::NA), + } + } + } + "SHEET" | "SHEETS" => { + let wb = self.host.workbook(); + if args.is_empty() { + return Ev::V(Value::number(if name == "SHEET" { self.sheet + 1 } else { wb.sheets.len() } as f64)); + } + match self.eval(&args[0]) { + Ev::R(a) => { + if name == "SHEET" { + a.first().map_or(err(CellError::Ref), |a| Ev::V(Value::number(a.sheet as f64 + 1.0))) + } else { + let mut s: Vec = a.iter().map(|x| x.sheet).collect(); + s.dedup(); + Ev::V(Value::number(s.len() as f64)) + } + } + Ev::V(Value::Text(t)) if name == "SHEET" => { + let wb = self.host.workbook(); + wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0))) + } + _ => err(CellError::NA), + } + } + "OFFSET" => self.offset(args), + "INDIRECT" => self.indirect(args), + "INDEX" => self.index(args), + "CELL" => self.cell_info(args), + "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), + "TEXT" => { + if args.len() != 2 { + return err(CellError::Value); + } + let v = self.arg_val(args, 0).unwrap_or_default(); + let f = self.arg_val(args, 1).unwrap_or_default(); + let sys = self.host.workbook().date_system; + let one = |v: &Value, f: &Value| -> Value { + if let Value::Error(e) = v { + return Value::Error(*e); + } + let code = match f.to_text() { + Ok(c) => c, + Err(e) => return Value::Error(e), + }; + match sheetcraft_numfmt::text_function(v, &code, sys) { + Ok(s) => Value::text(s), + Err(e) => Value::Error(e), + } + }; + Ev::V(zip2(&v, &f, one)) + } + "LET" => self.let_(args), + "LAMBDA" => { + if args.is_empty() { + return err(CellError::Value); + } + let mut params = Vec::new(); + for p in &args[..args.len() - 1] { + match p { + Expr::Name(n) => params.push(n.trim_start_matches('[').trim_end_matches(']').to_string()), + _ => return err(CellError::Value), + } + } + let Some(body) = args.last() else { return err(CellError::Value) }; + Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) + } + "ISOMITTED" => { + let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); + Ev::V(Value::Bool(r)) + } + "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), + "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { + self.host.note_volatile(); + self.builtin(name, args) + } + _ => self.builtin(name, args), + } + } + + fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { + let Some(spec) = sheetcraft_functions::lookup(name) else { + // A defined name holding a LAMBDA, or a LET-bound lambda. + if let Ev::L(l) = self.name(name) { + return self.apply_lambda(&l, args); + } + return err(CellError::Name); + }; + if args.len() < spec.min_args || args.len() > spec.max_args { + return err(CellError::Value); + } + let mut evaluated = Vec::with_capacity(args.len()); + for a in args { + let ev = self.eval(a); + let from_ref = matches!(ev, Ev::R(_)); + let value = match ev { + Ev::R(areas) if areas.len() > 1 => { + // Multi-area references (unions): flatten into one row for aggregates. + let mut vals = Vec::new(); + for ar in &areas { + match self.area_value(ar) { + Value::Array(a) => vals.extend(a.data.iter().cloned()), + v => vals.push(v), + } + } + Value::from(Array::row(vals)) + } + ev => self.deref(ev), + }; + evaluated.push(Arg { value, from_ref }); + } + let mut ctx = FnCtx { ev: self }; + sheetcraft_functions::call(spec, &evaluated, &mut ctx) + .pipe(Ev::V) + } + + pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { + if args.len() > l.params.len() { + return err(CellError::Value); + } + let mut bound = Vec::with_capacity(l.params.len()); + for (i, p) in l.params.iter().enumerate() { + let v = match args.get(i) { + Some(Expr::Missing) | None => Ev::V(Value::Empty), + Some(e) => self.eval(e), + }; + bound.push((p.clone(), v)); + } + self.call_lambda_values(l, bound) + } + + fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { + let saved = std::mem::replace(&mut self.env, l.env.clone()); + self.env.extend(bound); + let r = self.eval(&l.body); + self.env = saved; + r + } + + fn let_(&mut self, args: &[Expr]) -> Ev { + if args.len() < 3 || args.len().is_multiple_of(2) { + return err(CellError::Value); + } + let base = self.env.len(); + for pair in args[..args.len() - 1].chunks(2) { + let [Expr::Name(n), v] = pair else { + self.env.truncate(base); + return err(CellError::Value); + }; + let val = self.eval(v); + self.env.push((n.clone(), val)); + } + let r = match args.last() { + Some(body) => self.eval(body), + None => err(CellError::Value), + }; + self.env.truncate(base); + r + } + + fn lambda_arg(&mut self, e: Option<&Expr>) -> Option> { + match e.map(|x| self.eval(x)) { + Some(Ev::L(l)) => Some(l), + _ => None, + } + } + + fn higher_order(&mut self, name: &str, args: &[Expr]) -> Ev { + let as_array = |v: Value| -> Arc { + match v { + Value::Array(a) => a, + other => Arc::new(Array::scalar(other)), + } + }; + match name { + "MAP" => { + if args.len() < 2 { + return err(CellError::Value); + } + let Some(l) = self.lambda_arg(args.last()) else { return err(CellError::Value) }; + let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); + let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); + let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); + if (rows * cols) as u64 > MAX_CELLS { + return err(CellError::Num); + } + let mut out = Vec::with_capacity(rows * cols); + for r in 0..rows { + for c in 0..cols { + let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect(); + let v = self.call_lambda_values(&l, bound); + out.push(self.deref(v).scalar()); + } + } + Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } + "REDUCE" | "SCAN" => { + if args.len() != 3 { + return err(CellError::Value); + } + let mut acc = self.value(&args[0]); + let arr = as_array(self.value(&args[1])); + let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + if l.params.len() != 2 { + return err(CellError::Value); + } + let mut scan = Vec::with_capacity(arr.data.len()); + for v in arr.data.iter() { + let bound = vec![(l.params[0].clone(), Ev::V(acc.clone())), (l.params[1].clone(), Ev::V(v.clone()))]; + let r = self.call_lambda_values(&l, bound); + acc = self.deref(r); + if name == "SCAN" { + scan.push(acc.scalar()); + } + } + if name == "SCAN" { + Ev::V(Array::new(arr.rows, arr.cols, scan).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } else { + Ev::V(acc) + } + } + "BYROW" | "BYCOL" => { + if args.len() != 2 { + return err(CellError::Value); + } + let arr = as_array(self.value(&args[0])); + let Some(l) = self.lambda_arg(args.get(1)) else { return err(CellError::Value) }; + let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; + let by_row = name == "BYROW"; + let n = if by_row { arr.rows } else { arr.cols }; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let slice: Vec = if by_row { + (0..arr.cols).map(|c| arr.get(i, c).cloned().unwrap_or_default()).collect() + } else { + (0..arr.rows).map(|r| arr.get(r, i).cloned().unwrap_or_default()).collect() + }; + let a = if by_row { Array::row(slice) } else { Array::column(slice) }; + let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]); + out.push(self.deref(r).scalar()); + } + let a = if by_row { Array::column(out) } else { Array::row(out) }; + Ev::V(a.into()) + } + "MAKEARRAY" => { + if args.len() != 3 { + return err(CellError::Value); + } + let (rows, cols) = match (self.arg_num(args, 0, 0.0), self.arg_num(args, 1, 0.0)) { + (Ok(r), Ok(c)) => (r.trunc(), c.trunc()), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { + return err(CellError::Value); + } + let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) }; + if l.params.len() != 2 { + return err(CellError::Value); + } + let (rows, cols) = (rows as usize, cols as usize); + let mut out = Vec::with_capacity(rows * cols); + for r in 0..rows { + for c in 0..cols { + let bound = vec![(l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0)))]; + let v = self.call_lambda_values(&l, bound); + out.push(self.deref(v).scalar()); + } + } + Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } + _ => err(CellError::Name), + } + } + + /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. + fn trim_lines(&self, a: &Area) -> RangeRef { + if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range } + } + + fn offset(&mut self, args: &[Expr]) -> Ev { + self.host.note_volatile(); + if args.len() < 3 || args.len() > 5 { + return err(CellError::Value); + } + let base = match self.eval(&args[0]) { + Ev::R(a) if a.len() == 1 => a[0], + Ev::V(Value::Error(e)) => return err(e), + _ => return err(CellError::Value), + }; + let rows = match self.arg_num(args, 1, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let cols = match self.arg_num(args, 2, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let h = match self.arg_num(args, 3, base.range.height() as f64) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let w = match self.arg_num(args, 4, base.range.width() as f64) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + if h == 0 || w == 0 { + return err(CellError::Ref); + } + let r0 = base.range.start.row as i64 + rows; + let c0 = base.range.start.col as i64 + cols; + let (r0, r1) = if h > 0 { (r0, r0 + h - 1) } else { (r0 + h + 1, r0) }; + let (c0, c1) = if w > 0 { (c0, c0 + w - 1) } else { (c0 + w + 1, c0) }; + if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 { + return err(CellError::Ref); + } + Ev::R(vec![Area { sheet: base.sheet, range: RangeRef::new(CellRef::new(r0 as u32, c0 as u32), CellRef::new(r1 as u32, c1 as u32)) }]) + } + + fn indirect(&mut self, args: &[Expr]) -> Ev { + self.host.note_volatile(); + if args.is_empty() || args.len() > 2 { + return err(CellError::Value); + } + let text = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { + Some(Ok(t)) => t, + Some(Err(e)) => return err(e), + None => return err(CellError::Value), + }; + let a1 = match args.get(1) { + None | Some(Expr::Missing) => true, + Some(e) => { + let v = self.value(e); + v.scalar().to_bool().unwrap_or(true) + } + }; + let text = if a1 { text } else { r1c1_to_a1(&text, self.at).unwrap_or(text) }; + match sheetcraft_formula::parse(&text) { + Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { + r @ Ev::R(_) => r, + _ => err(CellError::Ref), + }, + _ => err(CellError::Ref), + } + } + + fn index(&mut self, args: &[Expr]) -> Ev { + if args.is_empty() || args.len() > 4 { + return err(CellError::Value); + } + let src = self.eval(&args[0]); + let row = match self.arg_num(args, 1, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let col = match self.arg_num(args, 2, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let area_n = match self.arg_num(args, 3, 1.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + if row < 0 || col < 0 { + return err(CellError::Value); + } + match src { + Ev::R(areas) => { + let Some(a) = areas.get((area_n - 1).max(0) as usize).copied() else { return err(CellError::Ref) }; + let r = a.range; + // A single row or column with one index picks along it. + let (row, col) = if args.len() == 2 && r.height() == 1 { (1, row) } else { (row, col) }; + if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { + return err(CellError::Ref); + } + let (r0, r1) = if row == 0 { (r.start.row, r.end.row) } else { (r.start.row + row as u32 - 1, r.start.row + row as u32 - 1) }; + let (c0, c1) = if col == 0 { (r.start.col, r.end.col) } else { (r.start.col + col as u32 - 1, r.start.col + col as u32 - 1) }; + Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) + } + Ev::V(Value::Array(arr)) => { + let (row, col) = if args.len() == 2 && arr.rows == 1 { (1, row) } else { (row, col) }; + if row as usize > arr.rows || col as usize > arr.cols { + return err(CellError::Ref); + } + match (row, col) { + (0, 0) => Ev::V(Value::Array(arr)), + (0, c) => Ev::V(Array::column((0..arr.rows).map(|r| arr.get(r, c as usize - 1).cloned().unwrap_or_default()).collect()).into()), + (r, 0) => Ev::V(Array::row((0..arr.cols).map(|c| arr.get(r as usize - 1, c).cloned().unwrap_or_default()).collect()).into()), + (r, c) => Ev::V(arr.get(r as usize - 1, c as usize - 1).cloned().unwrap_or(Value::Error(CellError::Ref))), + } + } + Ev::V(Value::Error(e)) => err(e), + Ev::V(v) => { + if row <= 1 && col <= 1 { + Ev::V(v) + } else { + err(CellError::Ref) + } + } + Ev::L(_) => err(CellError::Value), + } + } + + fn cell_info(&mut self, args: &[Expr]) -> Ev { + let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { + Some(Ok(t)) => t.to_ascii_lowercase(), + Some(Err(e)) => return err(e), + None => return err(CellError::Value), + }; + let area = match args.get(1).map(|a| self.eval(a)) { + Some(Ev::R(a)) => a.first().copied(), + None => Some(Area { sheet: self.sheet, range: RangeRef::cell(self.at) }), + _ => return err(CellError::Value), + }; + let Some(a) = area else { return err(CellError::Ref) }; + let c = a.range.start; + let wb = self.host.workbook(); + let sheet = wb.sheet(a.sheet); + match kind.as_str() { + "address" => Ev::V(Value::text(format!("${}${}", sheetcraft_core::col_to_letters(c.col), c.row + 1))), + "row" => Ev::V(Value::number(c.row as f64 + 1.0)), + "col" => Ev::V(Value::number(c.col as f64 + 1.0)), + "contents" => Ev::V(self.host.cell_value(a.sheet, c)), + "type" => { + let v = self.host.cell_value(a.sheet, c); + Ev::V(Value::text(match v { + Value::Empty => "b", + Value::Text(_) => "l", + _ => "v", + })) + } + "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| (s.col_width(c.col) as f64 / 7.0 * 100.0).round() / 100.0).round())), + "filename" => Ev::V(Value::text(String::new())), + "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), + "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), + "format" => { + let code = sheet.map(|s| wb.styles.get(s.style_id(c)).num_fmt.as_str().to_string()).unwrap_or_default(); + Ev::V(Value::text(cell_format_code(&code))) + } + "color" | "parentheses" => Ev::V(Value::Number(0.0)), + "prefix" => Ev::V(Value::text("")), + _ => err(CellError::Value), + } + } + + fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev { + if args.len() < 2 { + return err(CellError::Value); + } + let code = match self.arg_num(args, 0, 0.0) { + Ok(n) => n.trunc() as u32, + Err(e) => return err(e), + }; + let (func, options, rest) = if name == "AGGREGATE" { + let opt = match self.arg_num(args, 1, 0.0) { + Ok(n) => n.trunc() as u32, + Err(e) => return err(e), + }; + (code, opt, &args[2..]) + } else { + let (f, ignore_hidden) = if code > 100 { (code - 100, true) } else { (code, false) }; + (f, if ignore_hidden { 5 } else { 0 }, &args[1..]) + }; + if !(1..=19).contains(&func) || (name == "SUBTOTAL" && func > 11) { + return err(CellError::Value); + } + let skip_hidden = matches!(options, 1 | 3 | 5 | 7); + let skip_errors = matches!(options, 2 | 3 | 6 | 7); + let skip_nested = options <= 3; + let mut values = Vec::new(); + let mut k = None; + let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest }; + if name == "AGGREGATE" && func >= 14 { + k = rest.get(1).map(|e| self.value(e)); + } + for e in refs { + match self.eval(e) { + Ev::R(areas) => { + for a in areas { + let range = self.trim(&a); + for c in range.iter() { + let (hidden, nested) = { + let wb = self.host.workbook(); + let s = wb.sheet(a.sheet); + let hidden = skip_hidden && s.is_some_and(|s| s.is_row_hidden(c.row)); + let nested = skip_nested + && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| { + let u = f.text.to_ascii_uppercase(); + u.contains("SUBTOTAL(") || u.contains("AGGREGATE(") + }); + (hidden, nested) + }; + if hidden || nested { + continue; + } + let v = self.host.cell_value(a.sheet, c); + if skip_errors && v.is_error() { + continue; + } + values.push(v); + } + } + } + Ev::V(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()), + Ev::V(v) => values.push(v), + Ev::L(_) => return err(CellError::Value), + } + } + if let Some(k) = k { + values.push(Value::Empty); + return Ev::V(sheetcraft_functions::aggregate_values_k(func, &values[..values.len() - 1], &k)); + } + Ev::V(sheetcraft_functions::aggregate_values(func, &values)) + } +} + +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> T) -> T { + f(self) + } +} +impl Pipe for T {} + +/// CELL("format") code for a number format. +fn cell_format_code(code: &str) -> String { + let c = code.to_ascii_lowercase(); + if c == "general" { + return "G".into(); + } + if c.contains('%') { + return format!("P{}", decimals(&c)); + } + if c.contains('e') && c.contains('0') { + return format!("S{}", decimals(&c)); + } + if c.contains('$') { + return format!("C{}", decimals(&c)); + } + if c.contains('y') || c.contains('d') { + return "D1".into(); + } + if c.contains('h') { + return "D9".into(); + } + if c.contains(',') { + return format!(",{}", decimals(&c)); + } + format!("F{}", decimals(&c)) +} + +fn decimals(c: &str) -> usize { + c.split_once('.').map_or(0, |(_, d)| d.chars().take_while(|ch| *ch == '0').count()) +} + +fn same_kind(a: &Value, b: &Value) -> bool { + std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty()) +} + +/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). +pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option { + let (sheet, body) = match s.rsplit_once('!') { + Some((sh, b)) => (format!("{sh}!"), b), + None => (String::new(), s), + }; + let conv = |part: &str| -> Option { + let u = part.to_ascii_uppercase(); + let rest = u.strip_prefix('R')?; + let (rpart, cpart) = rest.split_once('C')?; + let num = |p: &str, base: u32| -> Option<(u32, bool)> { + if p.is_empty() { + return Some((base, false)); + } + if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { + let d: i64 = inner.parse().ok()?; + let v = base as i64 + d; + return if v >= 0 { Some((v as u32, false)) } else { None }; + } + let v: u32 = p.parse().ok()?; + Some((v.checked_sub(1)?, true)) + }; + let (r, ra) = num(rpart, at.row)?; + let (c, ca) = num(cpart, at.col)?; + Some(format!("{}{}{}{}", if ca { "$" } else { "" }, sheetcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1)) + }; + let out = match body.split_once(':') { + Some((a, b)) => format!("{}:{}", conv(a)?, conv(b)?), + None => conv(body)?, + }; + Some(format!("{sheet}{out}")) +} + +// ---------------------------------------------------------------- operators + +fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value { + match v { + Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus)).collect() }), + Value::Error(e) => Value::Error(*e), + Value::Text(_) if plus => v.clone(), + other => match other.to_number() { + Ok(n) => f(n), + Err(e) => Value::Error(e), + }, + } +} + +/// Element-wise combination with Excel's broadcasting. +pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value { + match (a, b) { + (Value::Array(x), Value::Array(y)) => { + let rows = broadcast_dim(x.rows, y.rows); + let cols = broadcast_dim(x.cols, y.cols); + let mut data = Vec::with_capacity(rows * cols); + for r in 0..rows { + for c in 0..cols { + data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))); + } + } + Value::from(Array { rows, cols, data }) + } + (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect() }), + (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }), + (x, y) => f(x, y), + } +} + +fn broadcast_dim(a: usize, b: usize) -> usize { + if a == 1 { + b + } else if b == 1 { + a + } else { + a.max(b) + } +} + +fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value { + let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) }; + let (tr, tc) = dims(t); + let (fr, fc) = dims(f); + let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr); + let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc); + let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() }; + let mut data = Vec::with_capacity(rows * cols); + for r in 0..rows { + for col in 0..cols { + data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col))); + } + } + Value::from(Array { rows, cols, data }) +} + +pub fn binary_values(op: BinOp, x: &Value, y: &Value) -> Value { + zip2(x, y, |a, b| binary_scalar(op, a, b)) +} + +fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value { + if let Value::Error(e) = a { + return Value::Error(*e); + } + if let Value::Error(e) = b { + return Value::Error(*e); + } + let num = |f: fn(f64, f64) -> Value| match (a.to_number(), b.to_number()) { + (Ok(x), Ok(y)) => f(x, y), + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }; + use std::cmp::Ordering::*; + match op { + BinOp::Add => num(|x, y| Value::number(x + y)), + BinOp::Sub => num(|x, y| Value::number(x - y)), + BinOp::Mul => num(|x, y| Value::number(x * y)), + BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }), + BinOp::Pow => num(|x, y| { + if x == 0.0 && y == 0.0 { + Value::Error(CellError::Num) + } else if x == 0.0 && y < 0.0 { + Value::Error(CellError::Div0) + } else { + Value::number(x.powf(y)) + } + }), + BinOp::Concat => match (a.to_text(), b.to_text()) { + (Ok(x), Ok(y)) => { + if x.len() + y.len() > 32767 * 4 { + Value::Error(CellError::Value) + } else { + Value::text(x + &y) + } + } + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }, + BinOp::Eq => Value::Bool(compare(a, b) == Equal), + BinOp::Ne => Value::Bool(compare(a, b) != Equal), + BinOp::Lt => Value::Bool(compare(a, b) == Less), + BinOp::Gt => Value::Bool(compare(a, b) == Greater), + BinOp::Le => Value::Bool(compare(a, b) != Greater), + BinOp::Ge => Value::Bool(compare(a, b) != Less), + BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), + } +} + +/// Collects the static references of an expression with their sheets resolved; `dynamic` is +/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. +pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { + let mut out = Vec::new(); + let mut dynamic = false; + e.walk(&mut |x| match x { + Expr::Ref(r) => { + let range = r.range(); + match &r.sheet { + SheetSel::Current => out.push(Area { sheet, range }), + SheetSel::Named(n) => { + if let Some(i) = wb.sheet_index(n) { + out.push(Area { sheet: i, range }); + } + } + SheetSel::Span(a, b) => { + if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { + for s in i.min(j)..=i.max(j) { + out.push(Area { sheet: s, range }); + } + } + } + } + } + Expr::Call(n, _) if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => dynamic = true, + Expr::Name(_) | Expr::Struct(_) => dynamic = true, + Expr::Call(n, _) if sheetcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, + _ => {} + }); + (out, dynamic) +} + +fn is_special(n: &str) -> bool { + matches!( + n, + "IF" | "IFS" + | "IFERROR" + | "IFNA" + | "CHOOSE" + | "SWITCH" + | "ROW" + | "COLUMN" + | "ROWS" + | "COLUMNS" + | "AREAS" + | "ISREF" + | "ISFORMULA" + | "FORMULATEXT" + | "SHEET" + | "SHEETS" + | "OFFSET" + | "INDIRECT" + | "INDEX" + | "CELL" + | "SUBTOTAL" + | "AGGREGATE" + | "TEXT" + | "LET" + | "LAMBDA" + | "ISOMITTED" + | "MAP" + | "REDUCE" + | "SCAN" + | "BYROW" + | "BYCOL" + | "MAKEARRAY" + ) +} + +/// Names of functions the evaluator implements itself (merged into the function list for UI). +pub const SPECIAL_FUNCTIONS: &[&str] = &[ + "IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH", "ROW", "COLUMN", "ROWS", "COLUMNS", "AREAS", "ISREF", "ISFORMULA", "FORMULATEXT", "SHEET", "SHEETS", "OFFSET", + "INDIRECT", "INDEX", "CELL", "SUBTOTAL", "AGGREGATE", "TEXT", "LET", "LAMBDA", "ISOMITTED", "MAP", "REDUCE", "SCAN", "BYROW", "BYCOL", "MAKEARRAY", +]; + +pub fn is_known_function(n: &str) -> bool { + is_special(&n.to_ascii_uppercase()) || sheetcraft_functions::lookup(n).is_some() +} + +#[allow(dead_code)] +fn unused(_: RefKind) {} diff --git a/crates/calc/src/lib.rs b/crates/calc/src/lib.rs new file mode 100644 index 00000000..0c381846 --- /dev/null +++ b/crates/calc/src/lib.rs @@ -0,0 +1,13 @@ +//! SheetCraft's calculation engine: the evaluator (references, names, tables, LET/LAMBDA, +//! dynamic arrays) and dependency-driven recalculation with spills and cycle detection. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod eval; +pub mod recalc; + +pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function}; +pub use recalc::{Calc, Key, evaluate, now_serial}; + +#[cfg(test)] +mod tests; diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs new file mode 100644 index 00000000..09daf9d4 --- /dev/null +++ b/crates/calc/src/recalc.rs @@ -0,0 +1,491 @@ +//! Dependency graph and recalculation. +//! +//! The graph maps every formula cell to its static precedents (resolved areas). A reverse index +//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges in +//! per-column buckets (wide ranges in a separate list). After an edit, the dirty set is the +//! transitive closure of dependents; it is evaluated in topological order, with on-demand +//! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative +//! calculation is on. + +use std::collections::{HashMap, HashSet, VecDeque}; + +use sheetcraft_core::{Array, CellError, CellRef, RangeRef, Value}; +use sheetcraft_formula::Expr; +use sheetcraft_model::{CalcMode, Workbook}; + +use crate::eval::{Area, Evaluator, Host, precedents}; + +pub type Key = (usize, CellRef); + +#[derive(Clone, Debug, Default)] +struct Node { + areas: Vec, + dynamic: bool, +} + +/// Ranges wider than this many columns go to the wide list instead of per-column buckets. +const WIDE: u32 = 64; + +#[derive(Clone, Debug, Default)] +pub struct Graph { + nodes: HashMap, + /// single cell → dependents + cell_deps: HashMap>, + /// (sheet, col) → [(r0, r1, dependent)] + col_deps: HashMap<(usize, u32), Vec<(u32, u32, Key)>>, + /// (sheet, range, dependent) + wide_deps: Vec<(usize, RangeRef, Key)>, + dynamic: HashSet, +} + +impl Graph { + pub fn len(&self) -> usize { + self.nodes.len() + } + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + fn insert(&mut self, k: Key, node: Node) { + self.remove(k); + for a in &node.areas { + if a.range.is_single() { + self.cell_deps.entry((a.sheet, a.range.start)).or_default().push(k); + } else if a.range.width() > WIDE { + self.wide_deps.push((a.sheet, a.range, k)); + } else { + for c in a.range.start.col..=a.range.end.col { + self.col_deps.entry((a.sheet, c)).or_default().push((a.range.start.row, a.range.end.row, k)); + } + } + } + if node.dynamic { + self.dynamic.insert(k); + } + self.nodes.insert(k, node); + } + + fn remove(&mut self, k: Key) { + let Some(old) = self.nodes.remove(&k) else { return }; + for a in &old.areas { + if a.range.is_single() { + if let Some(v) = self.cell_deps.get_mut(&(a.sheet, a.range.start)) { + v.retain(|x| *x != k); + } + } else if a.range.width() > WIDE { + self.wide_deps.retain(|(_, _, d)| *d != k); + } else { + for c in a.range.start.col..=a.range.end.col { + if let Some(v) = self.col_deps.get_mut(&(a.sheet, c)) { + v.retain(|(_, _, d)| *d != k); + } + } + } + } + self.dynamic.remove(&k); + } + + /// Formulas that read cell `c` of `sheet`. + pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec) { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend_from_slice(v); + } + if let Some(v) = self.col_deps.get(&(sheet, c.col)) { + out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= c.row && c.row <= *r1).map(|(_, _, k)| *k)); + } + out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.contains(c)).map(|(_, _, k)| *k)); + } + + /// Formulas that read any cell of `range`. + pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { + if range.count() <= 4096 { + for c in range.iter() { + self.dependents(sheet, c, out); + } + return; + } + for ((s, c), v) in &self.cell_deps { + if *s == sheet && range.contains(*c) { + out.extend_from_slice(v); + } + } + for ((s, col), v) in &self.col_deps { + if *s == sheet && range.start.col <= *col && *col <= range.end.col { + out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= range.end.row && range.start.row <= *r1).map(|(_, _, k)| *k)); + } + } + out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.intersects(&range)).map(|(_, _, k)| *k)); + } + + /// Precedent areas of a formula cell (for Trace Precedents). + pub fn precedents_of(&self, k: Key) -> Vec { + self.nodes.get(&k).map(|n| n.areas.clone()).unwrap_or_default() + } +} + +/// Calculation state kept beside a workbook. +#[derive(Clone, Debug)] +pub struct Calc { + pub graph: Graph, + rng: u64, + /// Fixed "now" for deterministic tests; `None` = system clock. + pub fixed_now: Option, + /// Cells whose formulas have errors from cycles. + pub circular: Vec, + pub last_recalc_cells: usize, +} + +impl Default for Calc { + fn default() -> Self { + Calc { graph: Graph::default(), rng: 0x9E37_79B9_7F4A_7C15, fixed_now: None, circular: vec![], last_recalc_cells: 0 } + } +} + +struct PassHost<'a> { + wb: &'a Workbook, + exprs: &'a HashMap, + results: HashMap, + pending: HashSet, + in_progress: HashSet, + /// New spills found during the pass: anchor → array. + spills: HashMap>, + rng: &'a mut u64, + now: f64, + cycle: bool, + cycles: Vec, + depth: usize, +} + +impl PassHost<'_> { + fn compute(&mut self, k: Key) -> Value { + if let Some(v) = self.results.get(&k) { + return v.clone(); + } + let Some(expr) = self.exprs.get(&k) else { + return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(); + }; + if self.in_progress.contains(&k) || self.depth > 2000 { + self.cycle = true; + self.cycles.push(k); + return Value::Error(CellError::Circ); + } + self.in_progress.insert(k); + self.depth += 1; + let expr = expr.clone(); + let v = { + let mut ev = Evaluator::new(self, k.0, k.1); + ev.value(&expr) + }; + self.depth -= 1; + self.in_progress.remove(&k); + self.pending.remove(&k); + let v = match v { + Value::Array(a) if a.rows == 1 && a.cols == 1 => a.data.first().cloned().unwrap_or_default(), + Value::Array(a) => { + self.spills.insert(k, a.clone()); + a.data.first().cloned().unwrap_or_default() + } + Value::Empty => Value::Number(0.0), + v => v, + }; + self.results.insert(k, v.clone()); + v + } +} + +impl Host for PassHost<'_> { + fn workbook(&self) -> &Workbook { + self.wb + } + fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { + let k = (sheet, c); + if self.pending.contains(&k) || self.results.contains_key(&k) { + return self.compute(k); + } + // A cell covered by a spill computed in this pass. + for (anchor, arr) in &self.spills { + if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col { + let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize); + if dr < arr.rows && dc < arr.cols { + return arr.get(dr, dc).cloned().unwrap_or_default(); + } + } + } + self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default() + } + fn now_serial(&self) -> f64 { + self.now + } + fn random(&mut self) -> f64 { + // xorshift64* + let mut x = *self.rng; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + *self.rng = x; + (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 + } +} + +impl Calc { + pub fn new() -> Self { + Self::default() + } + + fn now(&self) -> f64 { + if let Some(n) = self.fixed_now { + return n; + } + now_serial() + } + + /// Rebuilds the graph from scratch. + pub fn rebuild(&mut self, wb: &Workbook) { + self.graph = Graph::default(); + for (si, sheet) in wb.sheets.iter().enumerate() { + for (c, cell) in sheet.cells.iter() { + if let Some(f) = &cell.formula + && let Some(e) = f.expr() + { + let (areas, dynamic) = precedents(wb, si, &e); + self.graph.insert((si, c), Node { areas, dynamic }); + } + } + } + } + + /// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading). + pub fn recalc_all(&mut self, wb: &mut Workbook) { + self.rebuild(wb); + let all: Vec = self.graph.nodes.keys().copied().collect(); + self.run(wb, all, true); + } + + /// Updates the graph for cells whose content changed and recalculates what depends on them. + pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) { + for &k in changed { + let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone()); + match formula.and_then(|f| f.expr()) { + Some(e) => { + let (areas, dynamic) = precedents(wb, k.0, &e); + self.graph.insert(k, Node { areas, dynamic }); + } + None => { + self.graph.remove(k); + // A formula that doesn't parse evaluates to #NAME?. + if let Some(cell) = wb.sheet_mut(k.0).and_then(|s| s.cells.get_mut(k.1)) + && cell.formula.is_some() + { + cell.value = Value::Error(CellError::Name); + } + } + } + } + if wb.calc.mode == CalcMode::Manual { + // Only the edited formulas themselves are evaluated. + let own: Vec = changed.iter().copied().filter(|k| self.graph.nodes.contains_key(k)).collect(); + self.run(wb, own, false); + return; + } + let mut seeds: Vec = changed.to_vec(); + // Typing into (or clearing) a cell a dynamic array spills over (or would spill over) + // re-evaluates the anchor, which may now be blocked or unblocked. + for &k in changed { + if let Some(sh) = wb.sheet(k.0) { + for (anchor, r) in &sh.spill_ranges { + if *anchor != k.1 && r.contains(k.1) { + seeds.push((k.0, *anchor)); + } + } + for (c, cell) in sh.cells.iter_range(RangeRef::new(CellRef::new(k.1.row.saturating_sub(64), k.1.col.saturating_sub(64)), k.1)) { + if c != k.1 && cell.value == Value::Error(CellError::Spill) { + seeds.push((k.0, c)); + } + } + } + } + // Spill areas of changed anchors also change. + for &k in changed { + if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) { + seeds.extend(r.iter().take(65536).map(|c| (k.0, c))); + } + } + let dirty = self.dirty_closure(wb, &seeds); + self.run(wb, dirty, false); + } + + fn dirty_closure(&self, _wb: &Workbook, seeds: &[Key]) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + for k in seeds { + if self.graph.nodes.contains_key(k) && seen.insert(*k) { + queue.push_back(*k); + } + let mut deps = Vec::new(); + self.graph.dependents(k.0, k.1, &mut deps); + for d in deps { + if seen.insert(d) { + queue.push_back(d); + } + } + } + for k in &self.graph.dynamic { + if seen.insert(*k) { + queue.push_back(*k); + } + } + let mut deps = Vec::new(); + while let Some(k) = queue.pop_front() { + deps.clear(); + self.graph.dependents(k.0, k.1, &mut deps); + for d in deps.drain(..) { + if seen.insert(d) { + queue.push_back(d); + } + } + } + seen.into_iter().collect() + } + + /// Evaluates the dirty set and writes results (and spills) back. + fn run(&mut self, wb: &mut Workbook, mut dirty: Vec, full: bool) { + for _round in 0..8 { + if dirty.is_empty() { + break; + } + // Row-major order is a good topological guess; on-demand evaluation fixes the rest. + dirty.sort_by_key(|(s, c)| (*s, c.row, c.col)); + let mut exprs: HashMap = HashMap::with_capacity(dirty.len()); + for k in &dirty { + if let Some(e) = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.expr()) { + exprs.insert(*k, e); + } + } + let now = self.now(); + let mut rng = self.rng; + let (results, spills, cycles) = { + let mut host = PassHost { + wb, + exprs: &exprs, + results: HashMap::with_capacity(dirty.len()), + pending: exprs.keys().copied().collect(), + in_progress: HashSet::new(), + spills: HashMap::new(), + rng: &mut rng, + now, + cycle: false, + cycles: vec![], + depth: 0, + }; + for k in &dirty { + host.compute(*k); + } + (host.results, host.spills, host.cycles) + }; + self.rng = rng; + self.last_recalc_cells = results.len(); + self.circular = cycles; + // Write back. + let mut spill_changes: Vec = Vec::new(); + for (k, v) in results { + let Some(sheet) = wb.sheet_mut(k.0) else { continue }; + if let Some(cell) = sheet.cells.get_mut(k.1) + && cell.value != v + { + cell.value = v; + } + } + for k in &dirty { + let new = spills.get(k); + let Some(sheet) = wb.sheet_mut(k.0) else { continue }; + let old = sheet.spill_ranges.get(&k.1).copied(); + if old.is_none() && new.is_none() { + continue; + } + if let Some(r) = old { + for c in r.iter() { + sheet.spill.remove(&c); + } + sheet.spill_ranges.remove(&k.1); + } + let mut changed_range = old; + if let Some(arr) = new { + let end = CellRef::new(k.1.row.saturating_add(arr.rows as u32 - 1), k.1.col.saturating_add(arr.cols as u32 - 1)); + let range = RangeRef::new(k.1, end); + let blocked = end.row >= sheetcraft_core::MAX_ROWS + || end.col >= sheetcraft_core::MAX_COLS + || range.iter().any(|c| c != k.1 && (sheet.cells.has(c) || sheet.spill.contains_key(&c))) + || sheet.merges.iter().any(|m| m.intersects(&range)); + if blocked { + if let Some(cell) = sheet.cells.get_mut(k.1) { + cell.value = Value::Error(CellError::Spill); + } + } else { + for (i, c) in range.iter().enumerate() { + if c != k.1 { + sheet.spill.insert(c, arr.data.get(i).cloned().unwrap_or_default()); + } + } + sheet.spill_ranges.insert(k.1, range); + changed_range = Some(changed_range.map_or(range, |o| o.union(&range))); + } + } + if let Some(r) = changed_range { + let mut deps = Vec::new(); + self.graph.dependents_of_range(k.0, r, &mut deps); + spill_changes.extend(deps); + } + } + if full { + // Everything was already evaluated once; spill dependents need another pass. + } + spill_changes.sort_by_key(|(s, c)| (*s, c.row, c.col)); + spill_changes.dedup(); + spill_changes.retain(|k| !spills.contains_key(k)); + if spill_changes.is_empty() { + break; + } + dirty = self.dirty_closure(wb, &spill_changes); + } + } +} + +/// Local date-time as a serial (1900 system). Wasm without a clock returns a fixed date. +pub fn now_serial() -> f64 { + #[cfg(not(target_arch = "wasm32"))] + { + if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + // UTC; local offset isn't available without platform calls. + return 25569.0 + d.as_secs_f64() / 86400.0; + } + } + 46302.5 +} + +/// Evaluates a formula in the context of a cell without storing it (conditional formats, data +/// validation, Evaluate Formula, Name Manager previews, the CLI `eval`). +pub fn evaluate(wb: &Workbook, sheet: usize, at: CellRef, formula: &str) -> Value { + let body = formula.strip_prefix('=').unwrap_or(formula); + let Ok(expr) = sheetcraft_formula::parse(body) else { return Value::Error(CellError::Name) }; + evaluate_expr(wb, sheet, at, &expr) +} + +pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> Value { + let exprs = HashMap::new(); + let mut rng = 0x1234_5678_9ABC_DEF0u64; + let mut host = PassHost { + wb, + exprs: &exprs, + results: HashMap::new(), + pending: HashSet::new(), + in_progress: HashSet::new(), + spills: HashMap::new(), + rng: &mut rng, + now: now_serial(), + cycle: false, + cycles: vec![], + depth: 0, + }; + let mut ev = Evaluator::new(&mut host, sheet, at); + ev.value(expr) +} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs new file mode 100644 index 00000000..4b28cd04 --- /dev/null +++ b/crates/calc/src/tests.rs @@ -0,0 +1,213 @@ +use sheetcraft_core::{CellError, CellRef, Value}; +use sheetcraft_model::{Cell, Formula, Workbook}; + +use crate::Calc; + +fn c(s: &str) -> CellRef { + CellRef::parse(s).unwrap() +} + +struct T { + wb: Workbook, + calc: Calc, +} + +impl T { + fn new() -> T { + T { wb: Workbook::new(), calc: Calc::new() } + } + fn set(&mut self, at: &str, input: &str) { + let cell = if input.starts_with('=') { Cell::formula(Formula::new(input)) } else { Cell::value(sheetcraft_core::parse::parse_input(input, self.wb.date_system).value) }; + self.wb.sheet_mut(0).unwrap().cells.set(c(at), cell); + self.calc.cells_changed(&mut self.wb, &[(0, c(at))]); + } + fn get(&self, at: &str) -> Value { + self.wb.sheet(0).unwrap().value(c(at)) + } + fn num(&self, at: &str) -> f64 { + self.get(at).as_f64().unwrap_or_else(|| panic!("{at} = {:?}", self.get(at))) + } +} + +#[test] +fn arithmetic_and_dependencies() { + let mut t = T::new(); + t.set("A1", "2"); + t.set("A2", "3"); + t.set("A3", "=A1*A2+1"); + assert_eq!(t.num("A3"), 7.0); + t.set("A1", "10"); + assert_eq!(t.num("A3"), 31.0); + t.set("B1", "=SUM(A1:A3)"); + assert_eq!(t.num("B1"), 44.0); + t.set("A2", "0"); + assert_eq!(t.num("A3"), 1.0); + assert_eq!(t.num("B1"), 11.0); +} + +#[test] +fn chains_out_of_order() { + let mut t = T::new(); + t.set("A5", "=A4+1"); + t.set("A4", "=A3+1"); + t.set("A3", "=A2+1"); + t.set("A2", "=A1+1"); + t.set("A1", "1"); + assert_eq!(t.num("A5"), 5.0); +} + +#[test] +fn long_chain() { + let mut t = T::new(); + t.set("A1", "1"); + for r in 2..=3000 { + t.set(&format!("A{r}"), &format!("=A{}+1", r - 1)); + } + assert_eq!(t.num("A3000"), 3000.0); + t.set("A1", "10"); + assert_eq!(t.num("A3000"), 3009.0); +} + +#[test] +fn errors_and_cycles() { + let mut t = T::new(); + t.set("A1", "=1/0"); + assert_eq!(t.get("A1"), Value::Error(CellError::Div0)); + t.set("B1", "=B2"); + t.set("B2", "=B1"); + assert!(t.get("B1").is_error()); + t.set("C1", "=NOSUCHFN(1)"); + assert_eq!(t.get("C1"), Value::Error(CellError::Name)); + t.set("C2", "=1+"); + assert_eq!(t.get("C2"), Value::Error(CellError::Name)); +} + +#[test] +fn operators() { + let mut t = T::new(); + t.set("A1", "=\"a\"&1&TRUE"); + assert_eq!(t.get("A1"), Value::from("a1TRUE")); + t.set("A2", "=-2^2"); + assert_eq!(t.num("A2"), 4.0); + t.set("A3", "=50%"); + assert_eq!(t.num("A3"), 0.5); + t.set("A4", "=\"abc\"=\"ABC\""); + assert_eq!(t.get("A4"), Value::Bool(true)); + t.set("A5", "=\"5\"+1"); + assert_eq!(t.num("A5"), 6.0); + t.set("A6", "=\"x\"+1"); + assert_eq!(t.get("A6"), Value::Error(CellError::Value)); + t.set("A7", "=B99"); + assert_eq!(t.num("A7"), 0.0); +} + +#[test] +fn ifs_and_lazy() { + let mut t = T::new(); + t.set("A1", "5"); + t.set("B1", "=IF(A1>3,\"big\",1/0)"); + assert_eq!(t.get("B1"), Value::from("big")); + t.set("B2", "=IFERROR(1/0,\"oops\")"); + assert_eq!(t.get("B2"), Value::from("oops")); + t.set("B3", "=CHOOSE(2,\"a\",\"b\",\"c\")"); + assert_eq!(t.get("B3"), Value::from("b")); + t.set("B4", "=SWITCH(A1,1,\"one\",5,\"five\",\"other\")"); + assert_eq!(t.get("B4"), Value::from("five")); +} + +#[test] +fn references_functions() { + let mut t = T::new(); + for (i, v) in [10, 20, 30, 40].iter().enumerate() { + t.set(&format!("A{}", i + 1), &v.to_string()); + } + t.set("B1", "=ROW(A3)"); + assert_eq!(t.num("B1"), 3.0); + t.set("B2", "=ROWS(A1:A4)*COLUMNS(A1:C1)"); + assert_eq!(t.num("B2"), 12.0); + t.set("B3", "=SUM(OFFSET(A1,1,0,2,1))"); + assert_eq!(t.num("B3"), 50.0); + t.set("B4", "=INDIRECT(\"A\"&4)"); + assert_eq!(t.num("B4"), 40.0); + t.set("B5", "=SUM(A1:INDEX(A1:A4,3))"); + assert_eq!(t.num("B5"), 60.0); + t.set("B6", "=SUM(A:A)"); + assert_eq!(t.num("B6"), 100.0); + t.set("A2", "25"); + assert_eq!(t.num("B3"), 55.0); + assert_eq!(t.num("B6"), 105.0); +} + +#[test] +fn spills() { + let mut t = T::new(); + t.set("A1", "=SEQUENCE(3)"); + assert_eq!(t.num("A1"), 1.0); + assert_eq!(t.num("A3"), 3.0); + t.set("B1", "=SUM(A1:A3)"); + assert_eq!(t.num("B1"), 6.0); + t.set("A2", "x"); + assert_eq!(t.get("A1"), Value::Error(CellError::Spill)); + t.set("A2", ""); + assert_eq!(t.num("A3"), 3.0); + t.set("C1", "={1,2;3,4}*10"); + assert_eq!(t.num("D2"), 40.0); +} + +#[test] +fn let_lambda() { + let mut t = T::new(); + t.set("A1", "=LET(x,2,y,3,x*y)"); + assert_eq!(t.num("A1"), 6.0); + t.set("A2", "=LAMBDA(a,b,a+b)(2,3)"); + assert_eq!(t.num("A2"), 5.0); + t.set("A3", "=REDUCE(0,{1,2,3},LAMBDA(acc,v,acc+v))"); + assert_eq!(t.num("A3"), 6.0); + t.set("A4", "=SUM(MAP({1,2,3},LAMBDA(v,v*v)))"); + assert_eq!(t.num("A4"), 14.0); +} + +#[test] +fn names_and_sheets() { + let mut t = T::new(); + t.wb.names.push(sheetcraft_model::DefinedName { name: "Rate".into(), scope: None, formula: "0.5".into(), comment: String::new(), hidden: false }); + t.wb.sheets.push(std::sync::Arc::new(sheetcraft_model::Sheet::new("Data"))); + t.wb.sheet_mut(1).unwrap().set_value(c("A1"), Value::Number(8.0)); + t.calc.rebuild(&t.wb); + t.set("A1", "=Rate*Data!A1"); + assert_eq!(t.num("A1"), 4.0); + t.wb.sheet_mut(1).unwrap().set_value(c("A1"), Value::Number(10.0)); + t.calc.cells_changed(&mut t.wb, &[(1, c("A1"))]); + assert_eq!(t.num("A1"), 5.0); +} + +#[test] +fn text_function() { + let mut t = T::new(); + t.set("A1", "=TEXT(1234.5,\"#,##0.00\")"); + assert_eq!(t.get("A1"), Value::from("1,234.50")); +} + +#[test] +fn subtotal_skips_hidden() { + let mut t = T::new(); + t.set("A1", "1"); + t.set("A2", "2"); + t.set("A3", "3"); + t.wb.sheet_mut(0).unwrap().rows.insert(1, sheetcraft_model::LineInfo { hidden: true, ..Default::default() }); + t.set("B1", "=SUBTOTAL(109,A1:A3)"); + t.set("B2", "=SUBTOTAL(9,A1:A3)"); + assert_eq!(t.num("B1"), 4.0); + assert_eq!(t.num("B2"), 6.0); +} + +#[test] +fn recalc_all_after_load() { + let mut t = T::new(); + let s = t.wb.sheet_mut(0).unwrap(); + s.cells.set(c("A1"), Cell::value(Value::Number(4.0))); + s.cells.set(c("A2"), Cell::formula(Formula::new("=A1*2"))); + s.cells.set(c("A3"), Cell::formula(Formula::new("=A2+A1"))); + t.calc.recalc_all(&mut t.wb); + assert_eq!(t.num("A3"), 12.0); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 00000000..7bc6e5f3 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sheetcraft-core" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft core types: cell addresses, values, errors, date serials" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs new file mode 100644 index 00000000..8a649105 --- /dev/null +++ b/crates/core/src/addr.rs @@ -0,0 +1,280 @@ +//! Cell and range addresses. +//! +//! Internally rows and columns are 0-based (`A1` = row 0, col 0). The sheet is 1,048,576 rows by +//! 16,384 columns (`XFD1048576`), like Excel. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +pub const MAX_ROWS: u32 = 1_048_576; +pub const MAX_COLS: u32 = 16_384; + +/// A cell position (0-based). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Default)] +pub struct CellRef { + pub row: u32, + pub col: u32, +} + +impl CellRef { + pub const fn new(row: u32, col: u32) -> Self { + CellRef { row, col } + } + /// `A1`-style text, e.g. `B7`. + pub fn a1(&self) -> String { + format!("{}{}", col_to_letters(self.col), self.row as u64 + 1) + } + pub fn is_valid(&self) -> bool { + self.row < MAX_ROWS && self.col < MAX_COLS + } + /// Moved by `(dr, dc)`, `None` when the result leaves the sheet. + pub fn offset(&self, dr: i64, dc: i64) -> Option { + let r = self.row as i64 + dr; + let c = self.col as i64 + dc; + if (0..MAX_ROWS as i64).contains(&r) && (0..MAX_COLS as i64).contains(&c) { Some(CellRef::new(r as u32, c as u32)) } else { None } + } + /// Moved by `(dr, dc)` and clamped to the sheet. + pub fn offset_clamped(&self, dr: i64, dc: i64) -> CellRef { + let r = (self.row as i64 + dr).clamp(0, MAX_ROWS as i64 - 1); + let c = (self.col as i64 + dc).clamp(0, MAX_COLS as i64 - 1); + CellRef::new(r as u32, c as u32) + } + /// Parses `A1`, `$A$1`, `xfd1048576` (case-insensitive). `None` if malformed or out of range. + pub fn parse(s: &str) -> Option { + let (c, r, _, _, rest) = parse_a1_prefix(s)?; + if !rest.is_empty() { + return None; + } + Some(CellRef::new(r, c)) + } +} + +impl fmt::Display for CellRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}{}", col_to_letters(self.col), self.row as u64 + 1) + } +} + +/// Parses an A1 reference at the start of `s`: `(col, row, col_abs, row_abs, rest)`. +pub fn parse_a1_prefix(s: &str) -> Option<(u32, u32, bool, bool, &str)> { + let b = s.as_bytes(); + let mut i = 0; + let col_abs = b.first() == Some(&b'$'); + if col_abs { + i += 1; + } + let cs = i; + while i < b.len() && b[i].is_ascii_alphabetic() { + i += 1; + } + if i == cs || i - cs > 3 { + return None; + } + let col = letters_to_col(s.get(cs..i)?)?; + let row_abs = b.get(i) == Some(&b'$'); + if row_abs { + i += 1; + } + let rs = i; + while i < b.len() && b[i].is_ascii_digit() { + i += 1; + } + if i == rs || i - rs > 7 { + return None; + } + let row: u32 = s.get(rs..i)?.parse().ok()?; + if row == 0 || row > MAX_ROWS { + return None; + } + Some((col, row - 1, col_abs, row_abs, s.get(i..)?)) +} + +/// 0 → `A`, 25 → `Z`, 26 → `AA`, 16383 → `XFD`. +pub fn col_to_letters(col: u32) -> String { + let mut n = col as u64 + 1; + let mut out = Vec::with_capacity(3); + while n > 0 { + let rem = ((n - 1) % 26) as u8; + out.push(b'A' + rem); + n = (n - 1) / 26; + } + out.reverse(); + String::from_utf8(out).unwrap_or_default() +} + +/// `A` → 0, `xfd` → 16383. `None` for empty, non-letters or beyond `XFD`. +pub fn letters_to_col(s: &str) -> Option { + if s.is_empty() || s.len() > 3 { + return None; + } + let mut n: u32 = 0; + for ch in s.bytes() { + if !ch.is_ascii_alphabetic() { + return None; + } + n = n * 26 + (ch.to_ascii_uppercase() - b'A' + 1) as u32; + } + if n == 0 || n > MAX_COLS { + return None; + } + Some(n - 1) +} + +/// A rectangular block of cells, inclusive on both ends. Always normalised (`start <= end`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub struct RangeRef { + pub start: CellRef, + pub end: CellRef, +} + +impl RangeRef { + pub fn new(a: CellRef, b: CellRef) -> Self { + RangeRef { start: CellRef::new(a.row.min(b.row), a.col.min(b.col)), end: CellRef::new(a.row.max(b.row), a.col.max(b.col)) } + } + pub fn cell(c: CellRef) -> Self { + RangeRef { start: c, end: c } + } + /// Whole rows `r0..=r1`. + pub fn rows(r0: u32, r1: u32) -> Self { + RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, MAX_COLS - 1)) + } + /// Whole columns `c0..=c1`. + pub fn cols(c0: u32, c1: u32) -> Self { + RangeRef::new(CellRef::new(0, c0), CellRef::new(MAX_ROWS - 1, c1)) + } + pub fn all() -> Self { + RangeRef::new(CellRef::new(0, 0), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1)) + } + pub fn height(&self) -> u32 { + self.end.row - self.start.row + 1 + } + pub fn width(&self) -> u32 { + self.end.col - self.start.col + 1 + } + pub fn count(&self) -> u64 { + self.height() as u64 * self.width() as u64 + } + pub fn is_single(&self) -> bool { + self.start == self.end + } + pub fn is_full_rows(&self) -> bool { + self.start.col == 0 && self.end.col == MAX_COLS - 1 + } + pub fn is_full_cols(&self) -> bool { + self.start.row == 0 && self.end.row == MAX_ROWS - 1 + } + pub fn contains(&self, c: CellRef) -> bool { + c.row >= self.start.row && c.row <= self.end.row && c.col >= self.start.col && c.col <= self.end.col + } + pub fn contains_range(&self, o: &RangeRef) -> bool { + self.contains(o.start) && self.contains(o.end) + } + pub fn intersects(&self, o: &RangeRef) -> bool { + self.start.row <= o.end.row && o.start.row <= self.end.row && self.start.col <= o.end.col && o.start.col <= self.end.col + } + pub fn intersection(&self, o: &RangeRef) -> Option { + if !self.intersects(o) { + return None; + } + Some(RangeRef { + start: CellRef::new(self.start.row.max(o.start.row), self.start.col.max(o.start.col)), + end: CellRef::new(self.end.row.min(o.end.row), self.end.col.min(o.end.col)), + }) + } + pub fn union(&self, o: &RangeRef) -> RangeRef { + RangeRef { + start: CellRef::new(self.start.row.min(o.start.row), self.start.col.min(o.start.col)), + end: CellRef::new(self.end.row.max(o.end.row), self.end.col.max(o.end.col)), + } + } + /// Row-major iterator over the cells. Callers must bound huge ranges themselves. + pub fn iter(&self) -> impl Iterator + '_ { + let (r0, r1, c0, c1) = (self.start.row, self.end.row, self.start.col, self.end.col); + (r0..=r1).flat_map(move |r| (c0..=c1).map(move |c| CellRef::new(r, c))) + } + /// `A1:B2`, `A1` for single cells, `1:3` for full rows, `A:C` for full columns. + pub fn a1(&self) -> String { + if self.is_full_rows() && !self.is_full_cols() { + return format!("{}:{}", self.start.row + 1, self.end.row + 1); + } + if self.is_full_cols() && !self.is_full_rows() { + return format!("{}:{}", col_to_letters(self.start.col), col_to_letters(self.end.col)); + } + if self.is_single() { self.start.a1() } else { format!("{}:{}", self.start.a1(), self.end.a1()) } + } + /// Parses `A1`, `A1:B2`, `$A$1:$B$2`, `A:C`, `3:5` (no sheet prefix). + pub fn parse(s: &str) -> Option { + let s = s.trim(); + if let Some((a, b)) = s.split_once(':') { + if let (Some(x), Some(y)) = (CellRef::parse(a), CellRef::parse(b)) { + return Some(RangeRef::new(x, y)); + } + let strip = |t: &str| t.trim_start_matches('$').to_string(); + if let (Some(x), Some(y)) = (letters_to_col(&strip(a)), letters_to_col(&strip(b))) { + return Some(RangeRef::cols(x.min(y), x.max(y))); + } + let (pa, pb) = (strip(a).parse::().ok()?, strip(b).parse::().ok()?); + if pa == 0 || pb == 0 || pa > MAX_ROWS || pb > MAX_ROWS { + return None; + } + return Some(RangeRef::rows(pa.min(pb) - 1, pa.max(pb) - 1)); + } + CellRef::parse(s).map(RangeRef::cell) + } +} + +impl fmt::Display for RangeRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.a1()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letters_roundtrip() { + for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] { + assert_eq!(col_to_letters(c), s); + assert_eq!(letters_to_col(s), Some(c)); + } + assert_eq!(letters_to_col("XFE"), None); + assert_eq!(letters_to_col(""), None); + assert_eq!(letters_to_col("A1"), None); + } + + #[test] + fn parse_cells() { + assert_eq!(CellRef::parse("B7"), Some(CellRef::new(6, 1))); + assert_eq!(CellRef::parse("$b$7"), Some(CellRef::new(6, 1))); + assert_eq!(CellRef::parse("XFD1048576"), Some(CellRef::new(MAX_ROWS - 1, MAX_COLS - 1))); + assert_eq!(CellRef::parse("A0"), None); + assert_eq!(CellRef::parse("A1048577"), None); + assert_eq!(CellRef::parse("A1x"), None); + assert_eq!(CellRef::parse(""), None); + assert_eq!(CellRef::parse("$"), None); + } + + #[test] + fn ranges() { + let r = RangeRef::parse("C3:A1").unwrap(); + assert_eq!(r.a1(), "A1:C3"); + assert_eq!(r.count(), 9); + assert_eq!(RangeRef::parse("A:C").unwrap().a1(), "A:C"); + assert_eq!(RangeRef::parse("3:5").unwrap().a1(), "3:5"); + assert!(RangeRef::parse("0:5").is_none()); + let a = RangeRef::parse("A1:C3").unwrap(); + let b = RangeRef::parse("B2:D4").unwrap(); + assert_eq!(a.intersection(&b).unwrap().a1(), "B2:C3"); + assert_eq!(a.union(&b).a1(), "A1:D4"); + assert_eq!(a.iter().count(), 9); + } + + #[test] + fn offsets() { + assert_eq!(CellRef::new(0, 0).offset(-1, 0), None); + assert_eq!(CellRef::new(0, 0).offset_clamped(-5, 3), CellRef::new(0, 3)); + } +} diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs new file mode 100644 index 00000000..09540b62 --- /dev/null +++ b/crates/core/src/date.rs @@ -0,0 +1,184 @@ +//! Date serial numbers. +//! +//! The 1900 system counts days from 1899-12-31 (serial 1 = 1900-01-01) and, like Lotus 1-2-3 and +//! Excel, treats 1900 as a leap year: serial 60 is the fictitious 1900-02-29. The 1904 system +//! counts from 1904-01-01 (serial 0). Times are fractions of a day. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum DateSystem { + #[default] + D1900, + D1904, +} + +/// A calendar date and time of day. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct DateTime { + pub year: i32, + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, + pub second: u32, + /// Milliseconds (rounded). + pub milli: u32, + /// 0 = Sunday. + pub weekday: u32, +} + +/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01. +pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y as i64 - 1 } else { y as i64 }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let m = m as i64; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +pub fn civil_from_days(z: i64) -> (i32, u32, u32) { + let z = z + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + ((if m <= 2 { y + 1 } else { y }) as i32, m, d) +} + +const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30) +const UNIX_1904_01_01: i64 = -24107; + +pub fn is_leap(y: i32) -> bool { + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +} + +pub fn days_in_month(y: i32, m: u32) -> u32 { + match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if is_leap(y) => 29, + 2 => 28, + _ => 0, + } +} + +/// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next +/// year, day 0 = last day of the previous month). `None` before the epoch or after 9999-12-31. +pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Option { + // DATE(): years 0–1899 are added to 1900. + let mut y = if (0..1900).contains(&year) { year + 1900 } else { year }; + let m0 = month - 1; + y += m0.div_euclid(12); + let m = m0.rem_euclid(12) + 1; + if !(1900..=9999).contains(&y) && sys == DateSystem::D1900 || !(1904..=9999).contains(&y) && sys == DateSystem::D1904 { + return None; + } + let base = days_from_civil(y as i32, m as u32, 1) + day - 1; + let serial = match sys { + DateSystem::D1900 => { + let s = base - UNIX_1899_12_30; + // Before the fictitious 1900-02-29 serials are one lower than the day count from 1899-12-30. + if s <= 60 { s - 1 } else { s } + } + DateSystem::D1904 => base - UNIX_1904_01_01, + }; + if !(0..=2_958_465).contains(&serial) { + return None; + } + Some(serial as f64) +} + +/// Date/time from a serial. `None` for negative or too-large serials. +pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option { + if !serial.is_finite() || serial < 0.0 || serial >= 2_958_466.0 { + return None; + } + let mut day = serial.floor() as i64; + let mut frac_ms = ((serial - serial.floor()) * 86_400_000.0).round() as i64; + if frac_ms >= 86_400_000 { + frac_ms -= 86_400_000; + day += 1; + } + let (y, m, d, weekday) = match sys { + DateSystem::D1900 => { + // Excel's weekdays continue the fictitious calendar: serial 1 is a Sunday. + let wd = ((day + 6) % 7) as u32; + if day == 60 { + (1900, 2, 29, wd) + } else if day == 0 { + (1900, 1, 0, wd) + } else { + let real = if day < 60 { day + 1 } else { day }; + let (y, m, d) = civil_from_days(real + UNIX_1899_12_30); + (y, m, d, wd) + } + } + DateSystem::D1904 => { + let unix = day + UNIX_1904_01_01; + let (y, m, d) = civil_from_days(unix); + (y, m, d, ((unix + 4).rem_euclid(7)) as u32) + } + }; + let secs = frac_ms / 1000; + Some(DateTime { + year: y, + month: m, + day: d, + hour: (secs / 3600) as u32, + minute: ((secs / 60) % 60) as u32, + second: (secs % 60) as u32, + milli: (frac_ms % 1000) as u32, + weekday, + }) +} + +/// Fraction of a day for a time. +pub fn time_fraction(h: f64, m: f64, s: f64) -> f64 { + (h * 3600.0 + m * 60.0 + s) / 86400.0 +} + +pub const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_serials() { + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 1900, 1, 1), Some(1.0)); + assert_eq!(serial_from_ymd(s, 1900, 2, 28), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0)); + assert_eq!(serial_from_ymd(s, 2000, 1, 1), Some(36526.0)); + assert_eq!(serial_from_ymd(s, 2026, 10, 7), Some(46302.0)); + assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0)); + assert_eq!(serial_from_ymd(s, 2020, 13, 1), serial_from_ymd(s, 2021, 1, 1)); + assert_eq!(serial_from_ymd(s, 2020, 3, 0), serial_from_ymd(s, 2020, 2, 29)); + assert_eq!(serial_from_ymd(s, 120, 1, 1), serial_from_ymd(s, 2020, 1, 1)); + assert_eq!(serial_from_ymd(DateSystem::D1904, 1904, 1, 1), Some(0.0)); + assert_eq!(serial_from_ymd(DateSystem::D1904, 2000, 1, 1), Some(35064.0)); + } + + #[test] + fn roundtrip() { + let s = DateSystem::D1900; + for serial in [1.0, 59.0, 61.0, 36526.0, 46302.0, 2958465.0] { + let d = datetime_from_serial(s, serial).unwrap(); + assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial)); + } + let d = datetime_from_serial(s, 60.0).unwrap(); + assert_eq!((d.year, d.month, d.day), (1900, 2, 29)); + let d = datetime_from_serial(s, 46302.75).unwrap(); + assert_eq!((d.hour, d.minute, d.weekday), (18, 0, 3)); // 2026-10-07 is a Wednesday + let d = datetime_from_serial(s, 1.0).unwrap(); + assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 00000000..c2bc21b5 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,13 @@ +//! SheetCraft core types: cell addresses, values and Excel's coercion rules, error values, +//! date serials and typed-input parsing. No dependencies on the rest of the workspace. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod addr; +pub mod date; +pub mod parse; +pub mod value; + +pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col}; +pub use date::DateSystem; +pub use value::{Array, CellError, Value, compare, compare_text, number_to_text, sort_compare}; diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs new file mode 100644 index 00000000..babefb07 --- /dev/null +++ b/crates/core/src/parse.rs @@ -0,0 +1,358 @@ +//! Parsing what people type into a cell (and numeric text in formulas). +//! +//! Recognises numbers with thousands separators, percentages, currency, scientific notation, +//! fractions (`1 1/2`), dates (`3/4/2026`, `2026-03-04`, `4-Mar-2026`, `Mar 4, 2026`), times +//! (`10:30`, `10:30:15 PM`) and date-times. Locale: en-US. + +use crate::date::{DateSystem, MONTHS, serial_from_ymd, time_fraction}; +use crate::value::{CellError, Value}; + +/// A parsed entry: its value and the number format Excel would apply automatically. +#[derive(Clone, Debug, PartialEq)] +pub struct Parsed { + pub value: Value, + pub format: Option<&'static str>, +} + +/// Interprets typed cell input (not formulas). Text that isn't a number, date, boolean or error +/// stays text. A leading apostrophe forces text. +pub fn parse_input(s: &str, sys: DateSystem) -> Parsed { + if s.is_empty() { + return Parsed { value: Value::Empty, format: None }; + } + if let Some(rest) = s.strip_prefix('\'') { + return Parsed { value: Value::text(rest), format: None }; + } + let t = s.trim(); + if t.eq_ignore_ascii_case("TRUE") { + return Parsed { value: Value::Bool(true), format: None }; + } + if t.eq_ignore_ascii_case("FALSE") { + return Parsed { value: Value::Bool(false), format: None }; + } + if let Some(e) = CellError::parse(t) { + return Parsed { value: Value::Error(e), format: None }; + } + if let Some((n, f)) = parse_numeric(t, sys) { + return Parsed { value: Value::number(n), format: f }; + } + Parsed { value: Value::text(s), format: None } +} + +/// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00"). +pub fn parse_number_text(s: &str) -> Option { + parse_numeric(s.trim(), DateSystem::D1900).map(|(n, _)| n) +} + +fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { + if t.is_empty() { + return None; + } + // Parenthesised negatives: (100) = -100. + if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { + return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f)); + } + if let Some(r) = parse_plain(t) { + return Some(r); + } + if let Some(r) = parse_fraction(t) { + return Some((r, Some("# ?/?"))); + } + if let Some(r) = parse_datetime(t, sys) { + return Some(r); + } + None +} + +/// Signs, currency, thousands separators, percent, exponent. +fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { + let mut s = t; + let mut neg = false; + let mut currency = false; + let mut percent = false; + if let Some(r) = s.strip_prefix('-') { + neg = true; + s = r.trim_start(); + } else if let Some(r) = s.strip_prefix('+') { + s = r.trim_start(); + } + if let Some(r) = s.strip_prefix('$') { + currency = true; + s = r.trim_start(); + if !neg && let Some(r2) = s.strip_prefix('-') { + neg = true; + s = r2; + } + } + if let Some(r) = s.strip_suffix('%') { + percent = true; + s = r.trim_end(); + } + if s.is_empty() { + return None; + } + let has_sep = s.contains(','); + let digits: String = if has_sep { + // Thousands separators must be in groups of three before the decimal point. + let (int, frac) = s.split_once('.').map_or((s, None), |(a, b)| (a, Some(b))); + let groups: Vec<&str> = int.split(',').collect(); + let first = groups.first()?; + if first.is_empty() || first.len() > 3 || groups.iter().skip(1).any(|g| g.len() != 3) { + return None; + } + let mut d = groups.concat(); + if let Some(f) = frac { + d.push('.'); + d.push_str(f); + } + d + } else { + s.to_string() + }; + let b = digits.as_bytes(); + if !b.iter().all(|c| c.is_ascii_digit() || matches!(c, b'.' | b'e' | b'E' | b'+' | b'-')) { + return None; + } + if !b.first().is_some_and(|c| c.is_ascii_digit() || *c == b'.') { + return None; + } + let mut n: f64 = digits.parse().ok()?; + if !n.is_finite() { + return None; + } + let sci = digits.contains(['e', 'E']); + if percent { + n /= 100.0; + } + if neg { + n = -n; + } + let fmt = if currency { + Some(if digits.contains('.') { "\"$\"#,##0.00" } else { "\"$\"#,##0" }) + } else if percent { + Some(if digits.contains('.') { "0.00%" } else { "0%" }) + } else if sci { + Some("0.00E+00") + } else if has_sep { + Some(if digits.contains('.') { "#,##0.00" } else { "#,##0" }) + } else { + None + }; + Some((n, fmt)) +} + +/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`. +fn parse_fraction(t: &str) -> Option { + let (whole, frac) = t.split_once(' ')?; + let neg = whole.starts_with('-'); + let w: f64 = whole.trim_start_matches('-').parse().ok()?; + let (a, b) = frac.trim().split_once('/')?; + let a: f64 = a.parse().ok()?; + let b: f64 = b.parse().ok()?; + if b == 0.0 || !w.is_finite() { + return None; + } + let v = w + a / b; + Some(if neg { -v } else { v }) +} + +fn month_from_name(s: &str) -> Option { + let l = s.to_ascii_lowercase(); + if l.len() < 3 { + return None; + } + MONTHS.iter().position(|m| m.to_ascii_lowercase().starts_with(&l)).map(|i| i as u32 + 1) +} + +/// Time part: `h:mm`, `h:mm:ss`, `h:mm:ss.000`, `h AM`, with optional AM/PM. +fn parse_time(t: &str) -> Option<(f64, bool, bool)> { + let t = t.trim(); + let lower = t.to_ascii_lowercase(); + let (body, ampm) = if let Some(b) = lower.strip_suffix("am").or_else(|| lower.strip_suffix('a')) { + (b.trim().to_string(), Some(false)) + } else if let Some(b) = lower.strip_suffix("pm").or_else(|| lower.strip_suffix('p')) { + (b.trim().to_string(), Some(true)) + } else { + (lower.clone(), None) + }; + let parts: Vec<&str> = body.split(':').collect(); + if parts.len() == 1 && ampm.is_none() { + return None; + } + if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) { + return None; + } + let mut h: f64 = parts.first()?.parse().ok()?; + let m: f64 = parts.get(1).map_or(Some(0.0), |p| p.parse().ok())?; + let s: f64 = parts.get(2).map_or(Some(0.0), |p| p.parse().ok())?; + if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 { + return None; + } + if let Some(pm) = ampm { + if !(0.0..=12.0).contains(&h) { + return None; + } + if h == 12.0 { + h = 0.0; + } + if pm { + h += 12.0; + } + } else if h >= 10000.0 { + return None; + } + Some((time_fraction(h, m, s), parts.len() == 3, ampm.is_some())) +} + +fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> { + let t = t.trim().trim_end_matches(','); + let num = |s: &str| s.trim().parse::().ok(); + // ISO yyyy-mm-dd or yyyy/mm/dd + let seps: &[char] = &['/', '-', '.']; + let parts: Vec<&str> = t.split(seps).collect(); + if parts.len() == 3 { + let (a, b, c) = (parts[0].trim(), parts[1].trim(), parts[2].trim()); + if a.len() == 4 && let (Some(y), Some(m), Some(d)) = (num(a), num(b), num(c)) { + return valid_ymd(sys, y, m, d).map(|s| (s, "yyyy-mm-dd")); + } + if let (Some(m), Some(d), Some(y)) = (num(a), num(b), num(c)) { + let y = if c.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y }; + return valid_ymd(sys, y, m, d).map(|s| (s, "m/d/yyyy")); + } + // 4-Mar-2026 + if let (Some(d), Some(m), Some(y)) = (num(a), month_from_name(b), num(c)) { + let y = if c.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y }; + return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy")); + } + } + if parts.len() == 2 { + let (a, b) = (parts[0].trim(), parts[1].trim()); + // m/d in the current year is ambiguous without a clock; use month/day of 2026? Excel uses + // the current year. We accept d-mmm / mmm-d only with month names, and m/d → current year. + if let (Some(m), Some(d)) = (num(a), num(b)) { + let y = current_year(); + if (1..=12).contains(&m) && (1..=31).contains(&d) { + return valid_ymd(sys, y, m, d).map(|s| (s, "d-mmm")); + } + // m/yyyy + if (1..=12).contains(&m) && b.len() == 4 { + return valid_ymd(sys, d, m, 1).map(|s| (s, "mmm-yy")); + } + } + if let (Some(d), Some(m)) = (num(a), month_from_name(b)) { + return valid_ymd(sys, current_year(), m as i64, d).map(|s| (s, "d-mmm")); + } + if let (Some(m), Some(y)) = (month_from_name(a), num(b)) { + let y = if b.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y }; + return valid_ymd(sys, y, m as i64, 1).map(|s| (s, "mmm-yy")); + } + } + // "Mar 4, 2026" / "March 4 2026" / "4 March 2026" + let words: Vec<&str> = t.split([' ', ',']).filter(|w| !w.is_empty()).collect(); + if words.len() == 3 { + if let (Some(m), Some(d), Some(y)) = (month_from_name(words[0]), num(words[1]), num(words[2])) { + return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy")); + } + if let (Some(d), Some(m), Some(y)) = (num(words[0]), month_from_name(words[1]), num(words[2])) { + return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy")); + } + } + None +} + +fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option { + if !(1..=12).contains(&m) || d < 1 { + return None; + } + let max = if y == 1900 && m == 2 { 29 } else { crate::date::days_in_month(y as i32, m as u32) as i64 }; + if d > max { + return None; + } + serial_from_ymd(sys, y, m, d) +} + +/// The year used for dates typed without one. Fixed per process from the system clock when +/// available (wasm without clock falls back to 2026). +pub fn current_year() -> i64 { + #[cfg(not(target_arch = "wasm32"))] + { + if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + let days = (d.as_secs() / 86400) as i64; + return crate::date::civil_from_days(days).0 as i64; + } + } + 2026 +} + +fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { + if let Some((tf, secs, ampm)) = parse_time(t) { + let f = match (secs, ampm) { + (false, false) => "h:mm", + (true, false) => "h:mm:ss", + (false, true) => "h:mm AM/PM", + (true, true) => "h:mm:ss AM/PM", + }; + return Some((tf, Some(f))); + } + if let Some((d, f)) = parse_date(t, sys) { + return Some((d, Some(f))); + } + // date + time: split at the last space(s) before a time-looking tail. + for (i, _) in t.match_indices(' ') { + let (a, b) = (t.get(..i)?, t.get(i + 1..)?); + if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) { + return Some((d + tf, Some("m/d/yyyy h:mm"))); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(s: &str) -> Parsed { + parse_input(s, DateSystem::D1900) + } + + #[test] + fn numbers() { + assert_eq!(p("42").value, Value::Number(42.0)); + assert_eq!(p("-3.5").value, Value::Number(-3.5)); + assert_eq!(p("1,234").format, Some("#,##0")); + assert_eq!(p("1,234").value, Value::Number(1234.0)); + assert_eq!(p("1,23").value, Value::text("1,23")); + assert_eq!(p("12%").value, Value::Number(0.12)); + assert_eq!(p("$1,200.50").value, Value::Number(1200.5)); + assert_eq!(p("$1,200.50").format, Some("\"$\"#,##0.00")); + assert_eq!(p("(100)").value, Value::Number(-100.0)); + assert_eq!(p("1e3").value, Value::Number(1000.0)); + assert_eq!(p("1 1/2").value, Value::Number(1.5)); + assert_eq!(p(".5").value, Value::Number(0.5)); + } + + #[test] + fn texts_and_specials() { + assert_eq!(p("hello").value, Value::text("hello")); + assert_eq!(p("'123").value, Value::text("123")); + assert_eq!(p("true").value, Value::Bool(true)); + assert_eq!(p("#N/A").value, Value::Error(CellError::NA)); + assert_eq!(p("e").value, Value::text("e")); + assert_eq!(p("-").value, Value::text("-")); + assert_eq!(p("1.2.3.4").value, Value::text("1.2.3.4")); + } + + #[test] + fn dates_and_times() { + assert_eq!(p("10/7/2026").value, Value::Number(46302.0)); + assert_eq!(p("2026-10-07").value, Value::Number(46302.0)); + assert_eq!(p("7-Oct-2026").value, Value::Number(46302.0)); + assert_eq!(p("Oct 7, 2026").value, Value::Number(46302.0)); + assert_eq!(p("2/30/2026").value, Value::text("2/30/2026")); + assert_eq!(p("12:00").value, Value::Number(0.5)); + assert_eq!(p("6:00 PM").value, Value::Number(0.75)); + assert_eq!(p("12:00 AM").value, Value::Number(0.0)); + assert_eq!(p("10/7/2026 18:00").value, Value::Number(46302.75)); + assert_eq!(p("25:00").value, Value::Number(25.0 / 24.0)); + } +} diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs new file mode 100644 index 00000000..3d9736ed --- /dev/null +++ b/crates/core/src/value.rs @@ -0,0 +1,421 @@ +//! Cell values and Excel's coercion and comparison rules. + +use std::cmp::Ordering; +use std::fmt; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +/// Excel's error values. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CellError { + Null, + Div0, + Value, + Ref, + Name, + Num, + NA, + GettingData, + Spill, + Calc, + /// Circular reference that could not be resolved (shown as 0 by Excel; we keep it explicit). + Circ, +} + +impl CellError { + pub const ALL: [CellError; 10] = [ + CellError::Null, + CellError::Div0, + CellError::Value, + CellError::Ref, + CellError::Name, + CellError::Num, + CellError::NA, + CellError::GettingData, + CellError::Spill, + CellError::Calc, + ]; + pub fn as_str(&self) -> &'static str { + match self { + CellError::Null => "#NULL!", + CellError::Div0 => "#DIV/0!", + CellError::Value => "#VALUE!", + CellError::Ref => "#REF!", + CellError::Name => "#NAME?", + CellError::Num => "#NUM!", + CellError::NA => "#N/A", + CellError::GettingData => "#GETTING_DATA", + CellError::Spill => "#SPILL!", + CellError::Calc => "#CALC!", + CellError::Circ => "#CIRC!", + } + } + /// `ERROR.TYPE` number. + pub fn code(&self) -> u32 { + match self { + CellError::Null => 1, + CellError::Div0 => 2, + CellError::Value => 3, + CellError::Ref => 4, + CellError::Name => 5, + CellError::Num => 6, + CellError::NA => 7, + CellError::GettingData => 8, + CellError::Spill => 9, + CellError::Calc => 14, + CellError::Circ => 15, + } + } + pub fn parse(s: &str) -> Option { + let u = s.to_ascii_uppercase(); + CellError::ALL.iter().chain([CellError::Circ].iter()).find(|e| e.as_str() == u).copied() + } +} + +impl fmt::Display for CellError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A 2-D array of values (row-major). Always at least 1×1. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Array { + pub rows: usize, + pub cols: usize, + pub data: Vec, +} + +impl Array { + pub fn new(rows: usize, cols: usize, data: Vec) -> Option { + if rows == 0 || cols == 0 || rows.checked_mul(cols)? != data.len() { + return None; + } + Some(Array { rows, cols, data }) + } + pub fn filled(rows: usize, cols: usize, v: Value) -> Array { + let (rows, cols) = (rows.max(1), cols.max(1)); + Array { rows, cols, data: vec![v; rows.saturating_mul(cols)] } + } + pub fn scalar(v: Value) -> Array { + Array { rows: 1, cols: 1, data: vec![v] } + } + pub fn column(v: Vec) -> Array { + if v.is_empty() { + return Array::scalar(Value::Error(CellError::Calc)); + } + Array { rows: v.len(), cols: 1, data: v } + } + pub fn row(v: Vec) -> Array { + if v.is_empty() { + return Array::scalar(Value::Error(CellError::Calc)); + } + Array { rows: 1, cols: v.len(), data: v } + } + pub fn get(&self, r: usize, c: usize) -> Option<&Value> { + if r >= self.rows || c >= self.cols { + return None; + } + self.data.get(r * self.cols + c) + } + /// Excel's broadcasting read: a 1-row or 1-column array repeats along that axis; outside the + /// array is `#N/A`. + pub fn get_broadcast(&self, r: usize, c: usize) -> Value { + let rr = if self.rows == 1 { 0 } else { r }; + let cc = if self.cols == 1 { 0 } else { c }; + self.get(rr, cc).cloned().unwrap_or(Value::Error(CellError::NA)) + } + pub fn transpose(&self) -> Array { + let mut data = Vec::with_capacity(self.data.len()); + for c in 0..self.cols { + for r in 0..self.rows { + data.push(self.get(r, c).cloned().unwrap_or(Value::Empty)); + } + } + Array { rows: self.cols, cols: self.rows, data } + } + pub fn iter(&self) -> impl Iterator { + self.data.iter() + } +} + +/// A cell value or an intermediate formula result. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(tag = "t", content = "v")] +pub enum Value { + #[default] + Empty, + Number(f64), + Text(Arc), + Bool(bool), + Error(CellError), + Array(Arc), +} + +impl From for Value { + fn from(v: f64) -> Self { + Value::number(v) + } +} +impl From for Value { + fn from(v: bool) -> Self { + Value::Bool(v) + } +} +impl From<&str> for Value { + fn from(v: &str) -> Self { + Value::Text(Arc::from(v)) + } +} +impl From for Value { + fn from(v: String) -> Self { + Value::Text(Arc::from(v)) + } +} +impl From for Value { + fn from(v: CellError) -> Self { + Value::Error(v) + } +} +impl From for Value { + fn from(a: Array) -> Self { + Value::Array(Arc::new(a)) + } +} + +impl Value { + /// A number, or `#NUM!` when it is NaN or infinite (Excel never stores those). + pub fn number(v: f64) -> Value { + if v.is_finite() { Value::Number(if v == 0.0 { 0.0 } else { v }) } else { Value::Error(CellError::Num) } + } + pub fn text(s: impl Into>) -> Value { + Value::Text(s.into()) + } + pub fn is_empty(&self) -> bool { + matches!(self, Value::Empty) + } + pub fn is_error(&self) -> bool { + matches!(self, Value::Error(_)) + } + pub fn is_number(&self) -> bool { + matches!(self, Value::Number(_)) + } + pub fn is_text(&self) -> bool { + matches!(self, Value::Text(_)) + } + pub fn as_error(&self) -> Option { + if let Value::Error(e) = self { Some(*e) } else { None } + } + pub fn as_f64(&self) -> Option { + if let Value::Number(n) = self { Some(*n) } else { None } + } + pub fn as_text(&self) -> Option<&str> { + if let Value::Text(t) = self { Some(t) } else { None } + } + /// Top-left element of an array, the value itself otherwise. + pub fn scalar(&self) -> Value { + match self { + Value::Array(a) => a.data.first().cloned().unwrap_or(Value::Empty), + v => v.clone(), + } + } + + /// Coercion to a number as arithmetic does it: empty = 0, TRUE = 1, numeric text parses + /// (including dates, times, percentages and currency), other text = `#VALUE!`. + pub fn to_number(&self) -> Result { + match self { + Value::Empty => Ok(0.0), + Value::Number(n) => Ok(*n), + Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }), + Value::Text(t) => crate::parse::parse_number_text(t).ok_or(CellError::Value), + Value::Error(e) => Err(*e), + Value::Array(a) => a.data.first().map(|v| v.to_number()).unwrap_or(Ok(0.0)), + } + } + /// Coercion to text as `&` does it. + pub fn to_text(&self) -> Result { + match self { + Value::Empty => Ok(String::new()), + Value::Number(n) => Ok(number_to_text(*n)), + Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }), + Value::Text(t) => Ok(t.to_string()), + Value::Error(e) => Err(*e), + Value::Array(a) => a.data.first().map(|v| v.to_text()).unwrap_or(Ok(String::new())), + } + } + /// Coercion to a boolean as `IF` does it. + pub fn to_bool(&self) -> Result { + match self { + Value::Empty => Ok(false), + Value::Number(n) => Ok(*n != 0.0), + Value::Bool(b) => Ok(*b), + Value::Text(t) => { + if t.eq_ignore_ascii_case("TRUE") { + Ok(true) + } else if t.eq_ignore_ascii_case("FALSE") { + Ok(false) + } else { + Err(CellError::Value) + } + } + Value::Error(e) => Err(*e), + Value::Array(a) => a.data.first().map(|v| v.to_bool()).unwrap_or(Ok(false)), + } + } + /// Display text with General formatting (for logs, CSV and tests). + pub fn display(&self) -> String { + match self { + Value::Error(e) => e.as_str().to_string(), + v => v.to_text().unwrap_or_default(), + } + } + /// Type rank for comparison and sorting: numbers < text < booleans < errors; empty sorts last. + fn rank(&self) -> u8 { + match self { + Value::Number(_) => 0, + Value::Text(_) => 1, + Value::Bool(_) => 2, + Value::Error(_) => 3, + Value::Empty => 4, + Value::Array(_) => 5, + } + } +} + +/// Excel's comparison for `=`, `<`… operators. Text compares case-insensitively; empty acts as +/// 0, "" or FALSE depending on the other side. Errors are handled by the caller. +pub fn compare(a: &Value, b: &Value) -> Ordering { + match (a, b) { + (Value::Empty, Value::Empty) => Ordering::Equal, + (Value::Empty, Value::Number(n)) => 0.0f64.partial_cmp(n).unwrap_or(Ordering::Equal), + (Value::Number(n), Value::Empty) => n.partial_cmp(&0.0).unwrap_or(Ordering::Equal), + (Value::Empty, Value::Text(t)) => "".cmp(&t.to_lowercase() as &str), + (Value::Text(t), Value::Empty) => (t.to_lowercase() as String).as_str().cmp(""), + (Value::Empty, Value::Bool(b)) => false.cmp(b), + (Value::Bool(b), Value::Empty) => b.cmp(&false), + (Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal), + (Value::Text(x), Value::Text(y)) => compare_text(x, y), + (Value::Bool(x), Value::Bool(y)) => x.cmp(y), + (Value::Error(x), Value::Error(y)) => x.code().cmp(&y.code()), + _ => a.rank().cmp(&b.rank()), + } +} + +/// Case-insensitive text order (Unicode lowercase). +pub fn compare_text(a: &str, b: &str) -> Ordering { + let mut ai = a.chars().flat_map(char::to_lowercase); + let mut bi = b.chars().flat_map(char::to_lowercase); + loop { + match (ai.next(), bi.next()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(x), Some(y)) if x != y => return x.cmp(&y), + _ => {} + } + } +} + +/// Sorting order used by Sort (ascending): numbers, text, booleans, errors, then blanks last. +pub fn sort_compare(a: &Value, b: &Value) -> Ordering { + match (a, b) { + (Value::Empty, Value::Empty) => Ordering::Equal, + (Value::Empty, _) => Ordering::Greater, + (_, Value::Empty) => Ordering::Less, + (Value::Error(_), Value::Error(_)) => Ordering::Equal, + _ => compare(a, b), + } +} + +/// Number → text the way Excel converts in `&` and `TEXT(x,"General")`-free contexts: up to 15 +/// significant digits, scientific for very large or very small magnitudes. +pub fn number_to_text(n: f64) -> String { + if n == 0.0 || !n.is_finite() { + return "0".into(); + } + let a = n.abs(); + if !(1e-9..1e15).contains(&a) && a.round() != a || a >= 1e15 { + return sci_text(n, 15); + } + // 15 significant digits, trailing zeros trimmed. + let mag = a.log10().floor() as i32; + let decimals = (14 - mag).clamp(0, 30) as usize; + let s = format!("{:.*}", decimals, n); + trim_decimal(&s) +} + +/// Removes trailing zeros (and a trailing point) from a decimal string. +pub fn trim_decimal(s: &str) -> String { + if s.contains('.') { + let t = s.trim_end_matches('0').trim_end_matches('.'); + if t == "-0" { "0".into() } else { t.to_string() } + } else { + s.to_string() + } +} + +/// Scientific text like `1.23456789012346E+15` with up to `sig` significant digits. +pub fn sci_text(n: f64, sig: usize) -> String { + let s = format!("{:.*e}", sig.saturating_sub(1), n); + let Some((mant, exp)) = s.split_once('e') else { return s }; + let mant = trim_decimal(mant); + let e: i32 = exp.parse().unwrap_or(0); + format!("{}E{}{:02}", mant, if e < 0 { '-' } else { '+' }, e.abs()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn number_text() { + assert_eq!(number_to_text(1.0), "1"); + assert_eq!(number_to_text(-2.5), "-2.5"); + assert_eq!(number_to_text(0.1 + 0.2), "0.3"); + assert_eq!(number_to_text(123456789012345.0), "123456789012345"); + assert_eq!(number_to_text(1e15), "1E+15"); + assert_eq!(number_to_text(1234567890123456.0), "1.23456789012346E+15"); + assert_eq!(number_to_text(0.0001), "0.0001"); + assert_eq!(number_to_text(1e-10), "1E-10"); + assert_eq!(number_to_text(1.0 / 3.0), "0.333333333333333"); + } + + #[test] + fn coercion() { + assert_eq!(Value::from("12").to_number(), Ok(12.0)); + assert_eq!(Value::from(" 1,234.5 ").to_number(), Ok(1234.5)); + assert_eq!(Value::from("50%").to_number(), Ok(0.5)); + assert_eq!(Value::from("abc").to_number(), Err(CellError::Value)); + assert_eq!(Value::Bool(true).to_number(), Ok(1.0)); + assert_eq!(Value::Empty.to_text(), Ok(String::new())); + assert_eq!(Value::from("true").to_bool(), Ok(true)); + assert!(Value::number(f64::NAN).is_error()); + } + + #[test] + fn comparisons() { + assert_eq!(compare(&Value::from("abc"), &Value::from("ABC")), Ordering::Equal); + assert_eq!(compare(&Value::Number(5.0), &Value::from("1")), Ordering::Less); + assert_eq!(compare(&Value::from("z"), &Value::Bool(false)), Ordering::Less); + assert_eq!(compare(&Value::Empty, &Value::Number(0.0)), Ordering::Equal); + assert_eq!(sort_compare(&Value::Empty, &Value::Number(1.0)), Ordering::Greater); + } + + #[test] + fn errors() { + for e in CellError::ALL { + assert_eq!(CellError::parse(e.as_str()), Some(e)); + } + assert_eq!(CellError::parse("#div/0!"), Some(CellError::Div0)); + } + + #[test] + fn arrays() { + let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap(); + assert_eq!(a.transpose().get(0, 1), Some(&Value::Number(3.0))); + assert!(Array::new(2, 2, vec![]).is_none()); + let row = Array::row(vec![1.0.into(), 2.0.into()]); + assert_eq!(row.get_broadcast(5, 1), Value::Number(2.0)); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 00000000..eab7fcf8 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "sheetcraft-engine" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft engine: sessions, commands, history, clipboard, fill, sort, filter" + +[lints] +workspace = true + +[dependencies] +sheetcraft-core = { workspace = true } +sheetcraft-numfmt = { workspace = true } +sheetcraft-formula = { workspace = true } +sheetcraft-functions = { workspace = true } +sheetcraft-model = { workspace = true } +sheetcraft-calc = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +sheetcraft-xlsx = { workspace = true } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs new file mode 100644 index 00000000..64a7170d --- /dev/null +++ b/crates/engine/src/catalog.rs @@ -0,0 +1,326 @@ +//! Excel's ribbon and menu tree (feature names only), used as the parity metric: a catalog +//! entry counts as done when a command with its id is registered. `cargo xtask parity` writes +//! `docs/parity.md` from this list. + +/// (id, tab, group, label) +pub const CATALOG: &[(&str, &str, &str, &str)] = &[ + // File + ("file.new", "File", "File", "New"), + ("file.open", "File", "File", "Open"), + ("file.save", "File", "File", "Save"), + ("file.saveAs", "File", "File", "Save As"), + ("file.close", "File", "File", "Close"), + ("file.print", "File", "File", "Print"), + ("file.exportPdf", "File", "Export", "Export to PDF"), + ("file.exportCsv", "File", "Export", "CSV"), + ("file.exportHtml", "File", "Export", "Web Page"), + ("file.properties", "File", "Info", "Properties"), + ("file.options", "File", "Options", "Options"), + // Home + ("edit.paste", "Home", "Clipboard", "Paste"), + ("edit.pasteSpecial", "Home", "Clipboard", "Paste Special"), + ("edit.cut", "Home", "Clipboard", "Cut"), + ("edit.copy", "Home", "Clipboard", "Copy"), + ("edit.formatPainter", "Home", "Clipboard", "Format Painter"), + ("home.fontName", "Home", "Font", "Font"), + ("home.fontSize", "Home", "Font", "Font Size"), + ("home.increaseFontSize", "Home", "Font", "Increase Font Size"), + ("home.decreaseFontSize", "Home", "Font", "Decrease Font Size"), + ("home.bold", "Home", "Font", "Bold"), + ("home.italic", "Home", "Font", "Italic"), + ("home.underline", "Home", "Font", "Underline"), + ("home.doubleUnderline", "Home", "Font", "Double Underline"), + ("home.strikethrough", "Home", "Font", "Strikethrough"), + ("home.superscript", "Home", "Font", "Superscript"), + ("home.subscript", "Home", "Font", "Subscript"), + ("home.borders", "Home", "Font", "Borders"), + ("home.drawBorder", "Home", "Font", "Draw Border"), + ("home.fillColor", "Home", "Font", "Fill Color"), + ("home.fontColor", "Home", "Font", "Font Color"), + ("home.alignTop", "Home", "Alignment", "Top Align"), + ("home.alignMiddle", "Home", "Alignment", "Middle Align"), + ("home.alignBottom", "Home", "Alignment", "Bottom Align"), + ("home.orientation", "Home", "Alignment", "Orientation"), + ("home.alignLeft", "Home", "Alignment", "Align Left"), + ("home.alignCenter", "Home", "Alignment", "Center"), + ("home.alignRight", "Home", "Alignment", "Align Right"), + ("home.decreaseIndent", "Home", "Alignment", "Decrease Indent"), + ("home.increaseIndent", "Home", "Alignment", "Increase Indent"), + ("home.wrapText", "Home", "Alignment", "Wrap Text"), + ("home.mergeCenter", "Home", "Alignment", "Merge & Center"), + ("home.mergeAcross", "Home", "Alignment", "Merge Across"), + ("home.mergeCells", "Home", "Alignment", "Merge Cells"), + ("home.unmergeCells", "Home", "Alignment", "Unmerge Cells"), + ("home.numberFormat", "Home", "Number", "Number Format"), + ("home.accounting", "Home", "Number", "Accounting Number Format"), + ("home.percent", "Home", "Number", "Percent Style"), + ("home.comma", "Home", "Number", "Comma Style"), + ("home.increaseDecimal", "Home", "Number", "Increase Decimal"), + ("home.decreaseDecimal", "Home", "Number", "Decrease Decimal"), + ("home.conditionalFormat", "Home", "Styles", "Conditional Formatting"), + ("home.clearRules", "Home", "Styles", "Clear Rules"), + ("home.manageRules", "Home", "Styles", "Manage Rules"), + ("home.formatAsTable", "Home", "Styles", "Format as Table"), + ("home.cellStyle", "Home", "Styles", "Cell Styles"), + ("home.newCellStyle", "Home", "Styles", "New Cell Style"), + ("home.insertCells", "Home", "Cells", "Insert Cells"), + ("home.insertRows", "Home", "Cells", "Insert Sheet Rows"), + ("home.insertColumns", "Home", "Cells", "Insert Sheet Columns"), + ("home.insertSheet", "Home", "Cells", "Insert Sheet"), + ("home.deleteCells", "Home", "Cells", "Delete Cells"), + ("home.deleteRows", "Home", "Cells", "Delete Sheet Rows"), + ("home.deleteColumns", "Home", "Cells", "Delete Sheet Columns"), + ("home.deleteSheet", "Home", "Cells", "Delete Sheet"), + ("home.rowHeight", "Home", "Cells", "Row Height"), + ("home.autofitRowHeight", "Home", "Cells", "AutoFit Row Height"), + ("home.columnWidth", "Home", "Cells", "Column Width"), + ("home.autofitColumnWidth", "Home", "Cells", "AutoFit Column Width"), + ("home.defaultWidth", "Home", "Cells", "Default Width"), + ("home.hideRows", "Home", "Cells", "Hide Rows"), + ("home.hideColumns", "Home", "Cells", "Hide Columns"), + ("home.unhideRows", "Home", "Cells", "Unhide Rows"), + ("home.unhideColumns", "Home", "Cells", "Unhide Columns"), + ("sheet.hide", "Home", "Cells", "Hide Sheet"), + ("sheet.unhide", "Home", "Cells", "Unhide Sheet"), + ("sheet.rename", "Home", "Cells", "Rename Sheet"), + ("sheet.move", "Home", "Cells", "Move or Copy Sheet"), + ("sheet.tabColor", "Home", "Cells", "Tab Color"), + ("review.protectSheet", "Home", "Cells", "Protect Sheet"), + ("home.lockCell", "Home", "Cells", "Lock Cell"), + ("home.formatCells", "Home", "Cells", "Format Cells"), + ("formulas.autoSum", "Home", "Editing", "AutoSum"), + ("edit.fillDown", "Home", "Editing", "Fill Down"), + ("edit.fillRight", "Home", "Editing", "Fill Right"), + ("edit.fillUp", "Home", "Editing", "Fill Up"), + ("edit.fillLeft", "Home", "Editing", "Fill Left"), + ("edit.fillSeries", "Home", "Editing", "Series"), + ("edit.flashFill", "Home", "Editing", "Flash Fill"), + ("edit.clearAll", "Home", "Editing", "Clear All"), + ("edit.clearFormats", "Home", "Editing", "Clear Formats"), + ("edit.clearContents", "Home", "Editing", "Clear Contents"), + ("edit.clearComments", "Home", "Editing", "Clear Comments"), + ("edit.clearHyperlinks", "Home", "Editing", "Clear Hyperlinks"), + ("data.sortAscending", "Home", "Editing", "Sort A to Z"), + ("data.sortDescending", "Home", "Editing", "Sort Z to A"), + ("data.sort", "Home", "Editing", "Custom Sort"), + ("data.filter", "Home", "Editing", "Filter"), + ("data.clearFilter", "Home", "Editing", "Clear Filter"), + ("data.reapply", "Home", "Editing", "Reapply"), + ("edit.find", "Home", "Editing", "Find"), + ("edit.replace", "Home", "Editing", "Replace"), + ("edit.goTo", "Home", "Editing", "Go To"), + ("edit.goToSpecial", "Home", "Editing", "Go To Special"), + ("home.addIns", "Home", "Add-ins", "Add-ins"), + // Insert + ("insert.pivotTable", "Insert", "Tables", "PivotTable"), + ("insert.recommendedPivotTables", "Insert", "Tables", "Recommended PivotTables"), + ("insert.table", "Insert", "Tables", "Table"), + ("insert.picture", "Insert", "Illustrations", "Pictures"), + ("insert.shape", "Insert", "Illustrations", "Shapes"), + ("insert.icons", "Insert", "Illustrations", "Icons"), + ("insert.smartArt", "Insert", "Illustrations", "SmartArt"), + ("insert.screenshot", "Insert", "Illustrations", "Screenshot"), + ("insert.recommendedCharts", "Insert", "Charts", "Recommended Charts"), + ("insert.chart", "Insert", "Charts", "Charts"), + ("insert.pivotChart", "Insert", "Charts", "PivotChart"), + ("insert.map", "Insert", "Charts", "Maps"), + ("insert.sparkline", "Insert", "Sparklines", "Sparklines"), + ("insert.slicer", "Insert", "Filters", "Slicer"), + ("insert.timeline", "Insert", "Filters", "Timeline"), + ("insert.link", "Insert", "Links", "Link"), + ("review.newComment", "Insert", "Comments", "Comment"), + ("insert.textBox", "Insert", "Text", "Text Box"), + ("pageLayout.headerFooter", "Insert", "Text", "Header & Footer"), + ("insert.wordArt", "Insert", "Text", "WordArt"), + ("insert.signatureLine", "Insert", "Text", "Signature Line"), + ("insert.object", "Insert", "Text", "Object"), + ("insert.equation", "Insert", "Symbols", "Equation"), + ("insert.symbol", "Insert", "Symbols", "Symbol"), + ("insert.checkbox", "Insert", "Cell Controls", "Checkbox"), + // Draw + ("draw.pen", "Draw", "Tools", "Pen"), + ("draw.eraser", "Draw", "Tools", "Eraser"), + ("draw.lasso", "Draw", "Tools", "Lasso Select"), + ("draw.inkToShape", "Draw", "Convert", "Ink to Shape"), + // Page Layout + ("pageLayout.theme", "Page Layout", "Themes", "Themes"), + ("pageLayout.themeColors", "Page Layout", "Themes", "Colors"), + ("pageLayout.themeFonts", "Page Layout", "Themes", "Fonts"), + ("pageLayout.themeEffects", "Page Layout", "Themes", "Effects"), + ("pageLayout.margins", "Page Layout", "Page Setup", "Margins"), + ("pageLayout.orientation", "Page Layout", "Page Setup", "Orientation"), + ("pageLayout.size", "Page Layout", "Page Setup", "Size"), + ("pageLayout.printArea", "Page Layout", "Page Setup", "Print Area"), + ("pageLayout.breaks", "Page Layout", "Page Setup", "Breaks"), + ("pageLayout.background", "Page Layout", "Page Setup", "Background"), + ("pageLayout.printTitles", "Page Layout", "Page Setup", "Print Titles"), + ("pageLayout.pageSetup", "Page Layout", "Page Setup", "Page Setup"), + ("pageLayout.scaleToFit", "Page Layout", "Scale to Fit", "Scale to Fit"), + ("view.gridlines", "Page Layout", "Sheet Options", "View Gridlines"), + ("pageLayout.printGridlines", "Page Layout", "Sheet Options", "Print Gridlines"), + ("view.headings", "Page Layout", "Sheet Options", "View Headings"), + ("pageLayout.printHeadings", "Page Layout", "Sheet Options", "Print Headings"), + ("arrange.bringForward", "Page Layout", "Arrange", "Bring Forward"), + ("arrange.sendBackward", "Page Layout", "Arrange", "Send Backward"), + ("arrange.selectionPane", "Page Layout", "Arrange", "Selection Pane"), + ("arrange.align", "Page Layout", "Arrange", "Align"), + ("arrange.group", "Page Layout", "Arrange", "Group"), + ("arrange.rotate", "Page Layout", "Arrange", "Rotate"), + // Formulas + ("formulas.insertFunction", "Formulas", "Function Library", "Insert Function"), + ("formulas.recentlyUsed", "Formulas", "Function Library", "Recently Used"), + ("formulas.financial", "Formulas", "Function Library", "Financial"), + ("formulas.logical", "Formulas", "Function Library", "Logical"), + ("formulas.text", "Formulas", "Function Library", "Text"), + ("formulas.dateTime", "Formulas", "Function Library", "Date & Time"), + ("formulas.lookupReference", "Formulas", "Function Library", "Lookup & Reference"), + ("formulas.mathTrig", "Formulas", "Function Library", "Math & Trig"), + ("formulas.moreFunctions", "Formulas", "Function Library", "More Functions"), + ("formulas.nameManager", "Formulas", "Defined Names", "Name Manager"), + ("formulas.defineName", "Formulas", "Defined Names", "Define Name"), + ("formulas.useInFormula", "Formulas", "Defined Names", "Use in Formula"), + ("formulas.createFromSelection", "Formulas", "Defined Names", "Create from Selection"), + ("formulas.tracePrecedents", "Formulas", "Formula Auditing", "Trace Precedents"), + ("formulas.traceDependents", "Formulas", "Formula Auditing", "Trace Dependents"), + ("formulas.removeArrows", "Formulas", "Formula Auditing", "Remove Arrows"), + ("formulas.showFormulas", "Formulas", "Formula Auditing", "Show Formulas"), + ("formulas.errorChecking", "Formulas", "Formula Auditing", "Error Checking"), + ("formulas.evaluateFormula", "Formulas", "Formula Auditing", "Evaluate Formula"), + ("formulas.watchWindow", "Formulas", "Formula Auditing", "Watch Window"), + ("formulas.calculationOptions", "Formulas", "Calculation", "Calculation Options"), + ("formulas.calculateNow", "Formulas", "Calculation", "Calculate Now"), + ("formulas.calculateSheet", "Formulas", "Calculation", "Calculate Sheet"), + // Data + ("data.getData", "Data", "Get & Transform Data", "Get Data"), + ("data.fromTextCsv", "Data", "Get & Transform Data", "From Text/CSV"), + ("data.fromWeb", "Data", "Get & Transform Data", "From Web"), + ("data.fromTableRange", "Data", "Get & Transform Data", "From Table/Range"), + ("data.refreshAll", "Data", "Queries & Connections", "Refresh All"), + ("data.queriesConnections", "Data", "Queries & Connections", "Queries & Connections"), + ("data.stocks", "Data", "Data Types", "Stocks"), + ("data.geography", "Data", "Data Types", "Geography"), + ("data.sortAscending", "Data", "Sort & Filter", "Sort A to Z"), + ("data.sortDescending", "Data", "Sort & Filter", "Sort Z to A"), + ("data.sort", "Data", "Sort & Filter", "Sort"), + ("data.filter", "Data", "Sort & Filter", "Filter"), + ("data.clearFilter", "Data", "Sort & Filter", "Clear"), + ("data.reapply", "Data", "Sort & Filter", "Reapply"), + ("data.advancedFilter", "Data", "Sort & Filter", "Advanced"), + ("data.textToColumns", "Data", "Data Tools", "Text to Columns"), + ("data.flashFill", "Data", "Data Tools", "Flash Fill"), + ("data.removeDuplicates", "Data", "Data Tools", "Remove Duplicates"), + ("data.validation", "Data", "Data Tools", "Data Validation"), + ("data.circleInvalid", "Data", "Data Tools", "Circle Invalid Data"), + ("data.consolidate", "Data", "Data Tools", "Consolidate"), + ("data.relationships", "Data", "Data Tools", "Relationships"), + ("data.scenarioManager", "Data", "Forecast", "Scenario Manager"), + ("data.goalSeek", "Data", "Forecast", "Goal Seek"), + ("data.dataTable", "Data", "Forecast", "Data Table"), + ("data.forecastSheet", "Data", "Forecast", "Forecast Sheet"), + ("data.group", "Data", "Outline", "Group"), + ("data.ungroup", "Data", "Outline", "Ungroup"), + ("data.subtotal", "Data", "Outline", "Subtotal"), + ("data.showDetail", "Data", "Outline", "Show Detail"), + ("data.hideDetail", "Data", "Outline", "Hide Detail"), + ("data.analyzeData", "Data", "Analysis", "Analyze Data"), + ("data.solver", "Data", "Analysis", "Solver"), + ("data.analysisToolPak", "Data", "Analysis", "Data Analysis"), + // Review + ("review.spelling", "Review", "Proofing", "Spelling"), + ("review.thesaurus", "Review", "Proofing", "Thesaurus"), + ("review.workbookStatistics", "Review", "Proofing", "Workbook Statistics"), + ("review.checkAccessibility", "Review", "Accessibility", "Check Accessibility"), + ("review.smartLookup", "Review", "Insights", "Smart Lookup"), + ("review.translate", "Review", "Language", "Translate"), + ("review.newComment", "Review", "Comments", "New Comment"), + ("review.deleteComment", "Review", "Comments", "Delete Comment"), + ("review.previousComment", "Review", "Comments", "Previous Comment"), + ("review.nextComment", "Review", "Comments", "Next Comment"), + ("review.showComments", "Review", "Comments", "Show Comments"), + ("review.newNote", "Review", "Notes", "New Note"), + ("review.showNote", "Review", "Notes", "Show/Hide Note"), + ("review.showAllNotes", "Review", "Notes", "Show All Notes"), + ("review.convertToComments", "Review", "Notes", "Convert to Comments"), + ("review.protectSheet", "Review", "Protect", "Protect Sheet"), + ("review.unprotectSheet", "Review", "Protect", "Unprotect Sheet"), + ("review.protectWorkbook", "Review", "Protect", "Protect Workbook"), + ("review.allowEditRanges", "Review", "Protect", "Allow Edit Ranges"), + ("review.showChanges", "Review", "Changes", "Show Changes"), + // View + ("view.normal", "View", "Workbook Views", "Normal"), + ("view.pageBreakPreview", "View", "Workbook Views", "Page Break Preview"), + ("view.pageLayout", "View", "Workbook Views", "Page Layout"), + ("view.customViews", "View", "Workbook Views", "Custom Views"), + ("view.ruler", "View", "Show", "Ruler"), + ("view.formulaBar", "View", "Show", "Formula Bar"), + ("view.gridlines", "View", "Show", "Gridlines"), + ("view.headings", "View", "Show", "Headings"), + ("view.zoom", "View", "Zoom", "Zoom"), + ("view.zoom100", "View", "Zoom", "100%"), + ("view.zoomToSelection", "View", "Zoom", "Zoom to Selection"), + ("view.newWindow", "View", "Window", "New Window"), + ("view.arrangeAll", "View", "Window", "Arrange All"), + ("view.freezePanes", "View", "Window", "Freeze Panes"), + ("view.freezeTopRow", "View", "Window", "Freeze Top Row"), + ("view.freezeFirstColumn", "View", "Window", "Freeze First Column"), + ("view.unfreezePanes", "View", "Window", "Unfreeze Panes"), + ("view.split", "View", "Window", "Split"), + ("view.hideWindow", "View", "Window", "Hide"), + ("view.unhideWindow", "View", "Window", "Unhide"), + ("view.switchWindows", "View", "Window", "Switch Windows"), + ("view.macros", "View", "Macros", "Macros"), + // Automate + ("automate.newScript", "Automate", "Scripting Tools", "New Script"), + ("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"), + ("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"), + // Table Design + ("table.rename", "Table Design", "Properties", "Table Name"), + ("table.resize", "Table Design", "Properties", "Resize Table"), + ("table.summarizePivot", "Table Design", "Tools", "Summarize with PivotTable"), + ("table.removeDuplicates", "Table Design", "Tools", "Remove Duplicates"), + ("table.convertToRange", "Table Design", "Tools", "Convert to Range"), + ("table.insertSlicer", "Table Design", "Tools", "Insert Slicer"), + ("table.headerRow", "Table Design", "Table Style Options", "Header Row"), + ("table.totalRow", "Table Design", "Table Style Options", "Total Row"), + ("table.bandedRows", "Table Design", "Table Style Options", "Banded Rows"), + ("table.firstColumn", "Table Design", "Table Style Options", "First Column"), + ("table.lastColumn", "Table Design", "Table Style Options", "Last Column"), + ("table.bandedColumns", "Table Design", "Table Style Options", "Banded Columns"), + ("table.style", "Table Design", "Table Styles", "Table Styles"), + // Chart Design / Format + ("chart.addElement", "Chart Design", "Chart Layouts", "Add Chart Element"), + ("chart.quickLayout", "Chart Design", "Chart Layouts", "Quick Layout"), + ("chart.changeColors", "Chart Design", "Chart Styles", "Change Colors"), + ("chart.set", "Chart Design", "Chart Styles", "Chart Styles"), + ("chart.switchRowColumn", "Chart Design", "Data", "Switch Row/Column"), + ("chart.selectData", "Chart Design", "Data", "Select Data"), + ("chart.changeType", "Chart Design", "Type", "Change Chart Type"), + ("chart.move", "Chart Design", "Location", "Move Chart"), + ("chart.formatSelection", "Format", "Current Selection", "Format Selection"), + ("chart.shapeFill", "Format", "Shape Styles", "Shape Fill"), + ("chart.shapeOutline", "Format", "Shape Styles", "Shape Outline"), + // PivotTable Analyze / Design + ("pivot.fieldList", "PivotTable Analyze", "Show", "Field List"), + ("pivot.refresh", "PivotTable Analyze", "Data", "Refresh"), + ("pivot.changeSource", "PivotTable Analyze", "Data", "Change Data Source"), + ("pivot.calculatedField", "PivotTable Analyze", "Calculations", "Fields, Items & Sets"), + ("pivot.grandTotals", "PivotTable Design", "Layout", "Grand Totals"), + ("pivot.subtotals", "PivotTable Design", "Layout", "Subtotals"), + ("pivot.reportLayout", "PivotTable Design", "Layout", "Report Layout"), + ("pivot.style", "PivotTable Design", "PivotTable Styles", "PivotTable Styles"), +]; + +/// Distinct catalog ids. +pub fn ids() -> Vec<&'static str> { + let mut v: Vec<&str> = CATALOG.iter().map(|e| e.0).collect(); + v.sort_unstable(); + v.dedup(); + v +} + +/// (implemented, total) over distinct ids. +pub fn parity() -> (usize, usize) { + let ids = ids(); + let done = ids.iter().filter(|id| crate::find_command(id).is_some()).count(); + (done, ids.len()) +} diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs new file mode 100644 index 00000000..b5bd430b --- /dev/null +++ b/crates/engine/src/cf.rs @@ -0,0 +1,279 @@ +//! Conditional formatting evaluation: what extra style, data bar, colour scale or icon a cell +//! gets. + +use std::collections::HashMap; + +use sheetcraft_core::{CellRef, RangeRef, Value, compare}; +use sheetcraft_model::*; + +/// The conditional look of one cell. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CfLook { + /// Differential style (only non-default parts apply). + pub style: Option", esc(&sh.name)); + if let Some(r) = sh.used_range() { + for row in r.start.row..=r.end.row.min(r.start.row + 100_000) { + if sh.is_row_hidden(row) { + continue; + } + out.push_str(""); + for col in r.start.col..=r.end.col { + let c = CellRef::new(row, col); + if sh.merges.iter().any(|m| m.contains(c) && m.start != c) { + continue; + } + let st = wb.styles.get(sh.style_id(c)); + let mut css = String::new(); + if st.font.bold { + css.push_str("font-weight:bold;"); + } + if st.font.italic { + css.push_str("font-style:italic;"); + } + if let Some(h) = st.font.color.hex(&wb.theme) { + let _ = write!(css, "color:{h};"); + } + if st.fill.pattern != sheetcraft_model::PatternType::None + && let Some(h) = st.fill.fg.hex(&wb.theme) + { + let _ = write!(css, "background:{h};"); + } + let v = sh.value(c); + let align = match st.align.h { + sheetcraft_model::HAlign::Center => "center", + sheetcraft_model::HAlign::Right => "right", + sheetcraft_model::HAlign::Left => "left", + _ if v.is_number() => "right", + _ => "left", + }; + let _ = write!(css, "text-align:{align};"); + let span = sh.merges.iter().find(|m| m.start == c).map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())).unwrap_or_default(); + let _ = write!(out, "", esc(&crate::display::cell_text(wb, sh, c))); + } + out.push_str(""); + } + } + out.push_str("
{}
"); + out +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file(path: &str) -> Result> { + let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta.len() > 2 * 1024 * 1024 * 1024 { + return Err(EngineError::Other("files larger than 2 GB aren't supported".into())); + } + std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +#[cfg(target_arch = "wasm32")] +pub fn read_file(path: &str) -> Result> { + Err(EngineError::Other(format!("{path}: no file system in the browser"))) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + // Write to a temporary file, then rename: a crash never leaves a half-written workbook. + let tmp = format!("{path}.sctmp"); + std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +#[cfg(target_arch = "wasm32")] +pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { + Err(EngineError::Other(format!("{path}: no file system in the browser"))) +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; + for i in 0..4 { + if i <= chunk.len() { + out.push(B64[((n >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +pub fn base64_decode(s: &str) -> Option> { + let s = s.split_once(',').filter(|(h, _)| h.starts_with("data:")).map(|(_, b)| b).unwrap_or(s); + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let mut buf = 0u32; + let mut bits = 0; + for c in s.bytes() { + if c == b'=' || c.is_ascii_whitespace() { + continue; + } + let v = B64.iter().position(|&x| x == c)? as u32; + buf = (buf << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + Some(out) +} + +/// Pixel size of a PNG or JPEG from its header. +pub fn image_size(data: &[u8]) -> Option<(u32, u32)> { + if data.starts_with(&[0x89, b'P', b'N', b'G']) { + let w = u32::from_be_bytes(data.get(16..20)?.try_into().ok()?); + let h = u32::from_be_bytes(data.get(20..24)?.try_into().ok()?); + return Some((w, h)); + } + if data.starts_with(&[0xFF, 0xD8]) { + let mut i = 2; + while i + 9 < data.len() { + if *data.get(i)? != 0xFF { + i += 1; + continue; + } + let marker = *data.get(i + 1)?; + let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize; + if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) { + let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32; + let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32; + return Some((w, h)); + } + i += 2 + len; + } + } + None +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 00000000..62a530da --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,311 @@ +//! The SheetCraft engine façade. +//! +//! Every user-visible action is a command with a stable id (`cell.set`, `home.bold`, +//! `data.sortAscending`…) and JSON parameters. The egui UI, the CLI, the control channel and MCP +//! all go through [`Session::execute`], so every action is scriptable and journaled. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod catalog; +pub mod cf; +pub mod cmd; +pub mod display; +pub mod fill; +pub mod io; +pub mod sample; +pub mod selection; +pub mod tables; + +use std::sync::Arc; + +use serde::Serialize; +use serde_json::Value as Json; +use sheetcraft_calc::Calc; +use sheetcraft_core::{CellRef, RangeRef}; +use sheetcraft_model::Workbook; + +pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; +pub use selection::Selection; +pub use sheetcraft_calc as calc; +pub use sheetcraft_core as core; +pub use sheetcraft_formula as formula; +pub use sheetcraft_model as model; +pub use sheetcraft_numfmt as numfmt; + +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("command `{0}` is not available right now: {1}")] + Disabled(String, String), + #[error("invalid parameters for `{cmd}`: {msg}")] + BadParams { cmd: String, msg: String }, + #[error("no workbook open")] + NoDocument, + #[error("{0}")] + Other(String), + #[error("internal error in `{0}` (the workbook was kept as it was): {1}")] + Internal(String, String), +} + +pub type Result = std::result::Result; + +#[derive(Clone, Debug)] +pub struct HistoryEntry { + pub label: String, + pub wb: Arc, + pub selection: Selection, +} + +/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. +#[derive(Clone, Debug)] +pub struct Clipboard { + /// Source workbook snapshot, sheet and range. + pub wb: Arc, + pub sheet: usize, + pub range: RangeRef, + pub cut: bool, + /// Document uid of the source (a cut only moves within the same workbook). + pub doc_uid: u64, + /// Plain text (tab-separated) for the system clipboard. + pub text: String, +} + +/// In-cell edit mode as shown in the status bar. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub enum Mode { + #[default] + Ready, + Enter, + Edit, + Point, +} + +#[derive(Clone, Debug)] +pub struct DocState { + pub wb: Arc, + pub calc: Calc, + pub selection: Selection, + pub undo: Vec, + pub redo: Vec, + pub path: Option, + pub saved: Arc, + pub revision: u64, + pub uid: u64, + pub title: String, + /// Per-sheet selection memory. + pub sheet_selections: Vec, +} + +static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +const UNDO_LIMIT: usize = 100; + +impl DocState { + pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState { + let mut calc = Calc::new(); + calc.recalc_all(&mut wb); + let wb = Arc::new(wb); + let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default()); + DocState { + saved: wb.clone(), + wb, + calc, + selection: sel, + undo: vec![], + redo: vec![], + path, + revision: 1, + uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + title, + sheet_selections: vec![], + } + } + pub fn is_dirty(&self) -> bool { + !Arc::ptr_eq(&self.wb, &self.saved) + } + pub fn sheet_index(&self) -> usize { + self.wb.active_sheet + } + pub fn display_title(&self) -> String { + self.path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| self.title.clone()) + } +} + +/// Engine-side preferences. +#[derive(Clone, Debug, Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Prefs { + /// After pressing Enter, move the selection: "down", "right", "up", "left", "none". + pub enter_direction: String, + pub autocomplete: bool, + pub user_name: String, + pub default_font: String, + pub default_font_size: f32, + pub sheets_in_new_workbook: usize, + pub r1c1: bool, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { + enter_direction: "down".into(), + autocomplete: true, + user_name: "SheetCraft User".into(), + default_font: sheetcraft_model::DEFAULT_FONT.into(), + default_font_size: sheetcraft_model::DEFAULT_FONT_SIZE, + sheets_in_new_workbook: 1, + r1c1: false, + } + } +} + +/// Requests from commands to the UI (open a dialog, show a message…). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum UiRequest { + Dialog(String, Json), + Message(String), + /// Start editing the active cell (F2) with optional initial text. + EditCell(Option), + OpenUrl(String), +} + +#[derive(Default)] +pub struct Session { + docs: Vec, + active: usize, + pub clipboard: Option, + pub prefs: Prefs, + pub mode: Mode, + pub journal: Vec<(String, Json)>, + pub ui_requests: Vec, + /// Format painter source style (sticky when double-clicked). + pub format_painter: Option<(Arc, usize, RangeRef, bool)>, + /// Find state (last search). + pub last_find: Option, +} + +impl Session { + pub fn new() -> Session { + Session::default() + } + pub fn documents(&self) -> &[DocState] { + &self.docs + } + pub fn active_index(&self) -> usize { + self.active + } + pub fn set_active(&mut self, i: usize) { + if i < self.docs.len() { + self.active = i; + } + } + pub fn active(&self) -> Option<&DocState> { + self.docs.get(self.active) + } + pub fn active_mut(&mut self) -> Option<&mut DocState> { + self.docs.get_mut(self.active) + } + pub fn doc(&self) -> Result<&DocState> { + self.active().ok_or(EngineError::NoDocument) + } + pub fn doc_mut(&mut self) -> Result<&mut DocState> { + self.active_mut().ok_or(EngineError::NoDocument) + } + pub fn add_document(&mut self, d: DocState) -> usize { + self.docs.push(d); + self.active = self.docs.len() - 1; + self.active + } + pub fn close_document(&mut self, i: usize) { + if i < self.docs.len() { + self.docs.remove(i); + if self.active >= self.docs.len() { + self.active = self.docs.len().saturating_sub(1); + } + } + } + /// A new blank workbook titled `BookN`. + pub fn new_workbook(&mut self) -> usize { + let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1); + let mut wb = Workbook::new(); + for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) { + let name = wb.next_sheet_name(); + wb.sheets.push(Arc::new(sheetcraft_model::Sheet::new(name))); + } + self.add_document(DocState::new(wb, None, format!("Book{n}"))) + } + + pub fn commands(&self) -> Vec { + command_specs().iter().map(|c| c.info(self)).collect() + } + + /// Runs a command by id. Undoable commands record a history step when the workbook changed. + /// Panics inside commands are caught and reported; the workbook is restored. + pub fn execute(&mut self, id: &str, params: Json) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if let Err(why) = (spec.enabled)(self) { + return Err(EngineError::Disabled(id.to_string(), why)); + } + let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); + let params = if params.is_null() { Json::Object(Default::default()) } else { params }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms))); + let result = match result { + Ok(r) => r, + Err(p) => { + let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); + // Restore the workbook as it was. + if let Some((uid, wb, sel)) = &before + && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid) + { + d.wb = wb.clone(); + d.selection = sel.clone(); + d.calc.rebuild(&d.wb); + } + return Err(EngineError::Internal(id.to_string(), msg)); + } + }; + if spec.journal && result.is_ok() { + self.journal.push((id.to_string(), params.clone())); + if self.journal.len() > 10_000 { + self.journal.drain(..5_000); + } + } + if spec.undoable + && let Some((uid, wb, sel)) = before + && let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) + && !Arc::ptr_eq(&d.wb, &wb) + { + d.undo.push(HistoryEntry { label: spec.label.trim_end_matches('…').to_string(), wb, selection: sel }); + if d.undo.len() > UNDO_LIMIT { + d.undo.remove(0); + } + d.redo.clear(); + d.revision += 1; + } + result + } + + pub fn run(&mut self, id: &str, params: Json) -> std::result::Result { + self.execute(id, params).map_err(|e| e.to_string()) + } + + pub fn take_ui_requests(&mut self) -> Vec { + std::mem::take(&mut self.ui_requests) + } +} + +/// Convenience: the active workbook's active sheet. +pub fn active_sheet(s: &Session) -> Option<&sheetcraft_model::Sheet> { + s.active().and_then(|d| d.wb.active()) +} + +pub fn cell(r: u32, c: u32) -> CellRef { + CellRef::new(r, c) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs new file mode 100644 index 00000000..6dcb8fb0 --- /dev/null +++ b/crates/engine/src/sample.rs @@ -0,0 +1,188 @@ +//! Sample workbooks built in code (original content), for the start screen, demos, README +//! screenshots and tests. + +use std::sync::Arc; + +use serde_json::json; +use sheetcraft_model::{Sheet, Workbook}; + +use crate::{DocState, Session}; + +pub const SAMPLES: &[(&str, &str)] = &[("budget", "Household Budget"), ("sales", "Quarterly Sales Dashboard"), ("grades", "Gradebook")]; + +pub fn title(name: &str) -> String { + SAMPLES.iter().find(|(n, _)| *n == name).map(|(_, t)| t.to_string()).unwrap_or_else(|| "Sample".into()) +} + +/// Builds a sample by replaying commands (so samples also exercise the command layer). +pub fn build(name: &str) -> Option { + let mut s = Session::new(); + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + sh.name = match name { + "budget" => "Budget".into(), + "sales" => "Sales".into(), + "grades" => "Grades".into(), + _ => return None, + }; + } + s.add_document(DocState::new(wb, None, title(name))); + let ok = match name { + "budget" => budget(&mut s), + "sales" => sales(&mut s), + "grades" => grades(&mut s), + _ => false, + }; + if !ok { + return None; + } + let mut wb = (*s.doc().ok()?.wb).clone(); + wb.active_sheet = 0; + if let Some(sh) = wb.sheet_mut(0) { + sh.view_active = sheetcraft_core::CellRef::default(); + } + Some(wb) +} + +fn run(s: &mut Session, id: &str, p: serde_json::Value) -> bool { + match s.execute(id, p) { + Ok(_) => true, + Err(e) => { + log::warn!("sample: {id} failed: {e}"); + false + } + } +} + +fn sales(s: &mut Session) -> bool { + let rows = json!([ + ["Region", "Q1", "Q2", "Q3", "Q4", "Total", "Growth", "Trend"], + ["North", 48200, 51340, 56010, 61870], + ["South", 39150, 37980, 42400, 45120], + ["East", 55300, 58910, 57230, 63480], + ["West", 61020, 64400, 70150, 76390], + ["Central", 28740, 31260, 33980, 36510], + ["Online", 72410, 81630, 95220, 112840], + ]); + let mut ok = run(s, "range.setValues", json!({"range": "B4", "values": rows})); + ok &= run(s, "range.setValues", json!({"range": "B2", "values": [["Quarterly Sales Dashboard"]]})); + ok &= run(s, "home.cellStyle", json!({"range": "B2", "name": "Title"})); + ok &= run(s, "range.setValues", json!({"range": "B3", "values": [["Fiscal year 2026 · all figures in USD"]]})); + ok &= run(s, "home.cellStyle", json!({"range": "B3", "name": "Explanatory Text"})); + for r in 5..=10 { + ok &= run(s, "cell.set", json!({"cell": format!("G{r}"), "input": format!("=SUM(C{r}:F{r})")})); + ok &= run(s, "cell.set", json!({"cell": format!("H{r}"), "input": format!("=F{r}/C{r}-1")})); + ok &= run(s, "insert.sparkline", json!({"range": format!("C{r}:F{r}"), "location": format!("I{r}"), "type": "line", "markers": true})); + } + ok &= run(s, "insert.table", json!({"range": "B4:I10", "style": "TableStyleMedium2", "name": "Sales"})); + ok &= run(s, "table.totalRow", json!({"table": "Sales", "on": true})); + for col in ["Q1", "Q2", "Q3", "Q4", "Total"] { + ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": col, "function": "sum"})); + } + ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": "Growth", "function": "average"})); + ok &= run(s, "home.numberFormat", json!({"range": "C5:G11", "code": "\"$\"#,##0"})); + ok &= run(s, "home.numberFormat", json!({"range": "H5:H11", "code": "0.0%"})); + ok &= run(s, "home.conditionalFormat", json!({"range": "G5:G10", "rule": {"type": "dataBar", "color": "#5B9BD5"}})); + ok &= run(s, "home.conditionalFormat", json!({"range": "H5:H10", "rule": {"type": "iconSet", "set": "3Arrows"}})); + ok &= run(s, "home.columnWidth", json!({"cols": "A:A", "width": 20})); + ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 90})); + ok &= run(s, "home.columnWidth", json!({"cols": "C:G", "width": 84})); + ok &= run(s, "home.columnWidth", json!({"cols": "H:I", "width": 76})); + ok &= run(s, "selection.set", json!({"range": "B4:F10"})); + ok &= run(s, "insert.chart", json!({"type": "column", "title": "Revenue by Region", "at": "K3", "width": 460, "height": 270})); + ok &= run(s, "selection.set", json!({"range": "B4:B10"})); + // Pie of totals. + ok &= run(s, "insert.chart", json!({"range": "B4:B10", "type": "pie", "title": "Share of Annual Total", "at": "K19", "width": 460, "height": 260})); + if let Ok(d) = s.doc_mut() { + let wb = Arc::make_mut(&mut d.wb); + if let Some(sh) = wb.sheet_mut(0) + && let Some(c) = sh.charts.last_mut() + { + c.series = vec![sheetcraft_model::Series { name: Some("Sales!$G$4".into()), categories: Some("Sales!$B$5:$B$10".into()), values: "Sales!$G$5:$G$10".into(), bubble_sizes: None, color: None, secondary: false, kind: None }]; + } + } + ok &= run(s, "range.setValues", json!({"range": "B14", "values": [["Best region"], ["Average quarter"], ["Online share"], ["Year-over-year"]]})); + ok &= run(s, "cell.set", json!({"cell": "C14", "input": "=INDEX(Sales[Region],MATCH(MAX(Sales[Total]),Sales[Total],0))"})); + ok &= run(s, "cell.set", json!({"cell": "C15", "input": "=AVERAGE(C5:F10)"})); + ok &= run(s, "cell.set", json!({"cell": "C16", "input": "=G10/SUM(G5:G10)"})); + ok &= run(s, "cell.set", json!({"cell": "C17", "input": "=SUM(F5:F10)/SUM(C5:C10)-1"})); + ok &= run(s, "home.numberFormat", json!({"range": "C15", "code": "\"$\"#,##0"})); + ok &= run(s, "home.numberFormat", json!({"range": "C16:C17", "code": "0.0%"})); + ok &= run(s, "home.bold", json!({"range": "B14:B17", "on": true})); + ok &= run(s, "home.borders", json!({"range": "B14:C17", "preset": "outside"})); + ok &= run(s, "home.fillColor", json!({"range": "B14:C17", "color": "#F2F7FC"})); + ok &= run(s, "review.newNote", json!({"cell": "H4", "text": "Q4 vs Q1 growth for each region."})); + ok &= run(s, "selection.set", json!({"cell": "B2"})); + ok +} + +fn budget(s: &mut Session) -> bool { + let rows = json!([ + ["Category", "Budget", "Actual", "Difference", "% Used"], + ["Rent", 1850, 1850], + ["Groceries", 620, 684.35], + ["Utilities", 240, 212.8], + ["Transport", 180, 203.5], + ["Internet & phone", 95, 95], + ["Insurance", 210, 210], + ["Dining out", 200, 265.9], + ["Entertainment", 120, 88.25], + ["Savings", 600, 600], + ["Art supplies", 150, 172.4], + ]); + let mut ok = run(s, "range.setValues", json!({"range": "B2", "values": [["Monthly Budget — October 2026"]]})); + ok &= run(s, "home.cellStyle", json!({"range": "B2", "name": "Title"})); + ok &= run(s, "range.setValues", json!({"range": "B4", "values": rows})); + for r in 5..=14 { + ok &= run(s, "cell.set", json!({"cell": format!("E{r}"), "input": format!("=C{r}-D{r}")})); + ok &= run(s, "cell.set", json!({"cell": format!("F{r}"), "input": format!("=D{r}/C{r}")})); + } + ok &= run(s, "range.setValues", json!({"range": "B15", "values": [["Total"]]})); + for c in ["C", "D", "E"] { + ok &= run(s, "cell.set", json!({"cell": format!("{c}15"), "input": format!("=SUM({c}5:{c}14)")})); + } + ok &= run(s, "cell.set", json!({"cell": "F15", "input": "=D15/C15"})); + ok &= run(s, "home.cellStyle", json!({"range": "B4:F4", "name": "Heading 3"})); + ok &= run(s, "home.cellStyle", json!({"range": "B15:F15", "name": "Total"})); + ok &= run(s, "home.numberFormat", json!({"range": "C5:E15", "format": "Accounting"})); + ok &= run(s, "home.numberFormat", json!({"range": "F5:F15", "code": "0%"})); + ok &= run(s, "home.conditionalFormat", json!({"range": "E5:E14", "rule": {"type": "cellIs", "operator": "less", "value": 0, "preset": "lightRedFillDarkRedText"}})); + ok &= run(s, "home.conditionalFormat", json!({"range": "F5:F14", "rule": {"type": "colorScale", "colors": ["#63BE7B", "#FFEB84", "#F8696B"]}})); + ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 130})); + ok &= run(s, "home.columnWidth", json!({"cols": "C:E", "width": 90})); + ok &= run(s, "selection.set", json!({"range": "B4:D14"})); + ok &= run(s, "insert.chart", json!({"type": "bar", "title": "Budget vs Actual", "at": "H3", "width": 440, "height": 300})); + ok &= run(s, "view.freezePanes", json!({"cell": "A5"})); + ok &= run(s, "selection.set", json!({"cell": "B2"})); + ok +} + +fn grades(s: &mut Session) -> bool { + let rows = json!([ + ["Student", "Essay", "Midterm", "Project", "Final", "Average", "Grade"], + ["Amara Okafor", 92, 88, 95, 91], + ["Bruno Silva", 78, 82, 74, 80], + ["Chen Wei", 85, 91, 89, 94], + ["Dana Kowalski", 67, 72, 80, 70], + ["Elif Yılmaz", 98, 95, 97, 99], + ["Farid Haddad", 74, 69, 71, 77], + ["Grace Mensah", 88, 84, 90, 86], + ["Hiro Tanaka", 59, 64, 70, 62], + ]); + let mut ok = run(s, "range.setValues", json!({"range": "A1", "values": rows})); + for r in 2..=9 { + ok &= run(s, "cell.set", json!({"cell": format!("F{r}"), "input": format!("=AVERAGE(B{r}:E{r})")})); + ok &= run(s, "cell.set", json!({"cell": format!("G{r}"), "input": format!("=IFS(F{r}>=90,\"A\",F{r}>=80,\"B\",F{r}>=70,\"C\",F{r}>=60,\"D\",TRUE,\"F\")")})); + } + ok &= run(s, "home.numberFormat", json!({"range": "F2:F9", "code": "0.0"})); + ok &= run(s, "insert.table", json!({"range": "A1:G9", "style": "TableStyleLight9", "name": "Grades"})); + ok &= run(s, "home.columnWidth", json!({"cols": "A:A", "width": 120})); + ok &= run(s, "home.conditionalFormat", json!({"range": "G2:G9", "rule": {"type": "cellIs", "operator": "equal", "value": "A", "preset": "greenFillDarkGreenText"}})); + ok &= run(s, "home.conditionalFormat", json!({"range": "F2:F9", "rule": {"type": "dataBar", "color": "#63BE7B"}})); + ok +} + +/// A blank sheet (for callers that need one without a session). +pub fn blank_sheet(name: &str) -> Sheet { + Sheet::new(name) +} diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs new file mode 100644 index 00000000..24915217 --- /dev/null +++ b/crates/engine/src/selection.rs @@ -0,0 +1,237 @@ +//! Cell selection: one or more ranges with an active cell. + +use serde::{Deserialize, Serialize}; +use sheetcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; +use sheetcraft_model::Sheet; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Selection { + /// The active (white) cell. + pub active: CellRef, + /// Where a Shift-extension is anchored. + pub anchor: CellRef, + /// Selected areas; the last one contains the active cell. Never empty. + pub ranges: Vec, +} + +impl Default for Selection { + fn default() -> Self { + Selection::at(CellRef::default()) + } +} + +impl Selection { + pub fn at(c: CellRef) -> Selection { + Selection { active: c, anchor: c, ranges: vec![RangeRef::cell(c)] } + } + pub fn range(r: RangeRef) -> Selection { + Selection { active: r.start, anchor: r.start, ranges: vec![r] } + } + /// The current (last) area. + pub fn current(&self) -> RangeRef { + self.ranges.last().copied().unwrap_or(RangeRef::cell(self.active)) + } + /// Bounding range of all areas. + pub fn bounds(&self) -> RangeRef { + let mut it = self.ranges.iter(); + let first = it.next().copied().unwrap_or(RangeRef::cell(self.active)); + it.fold(first, |a, b| a.union(b)) + } + pub fn contains(&self, c: CellRef) -> bool { + self.ranges.iter().any(|r| r.contains(c)) + } + pub fn is_single_cell(&self) -> bool { + self.ranges.len() == 1 && self.current().is_single() + } + /// Extends the current area from the anchor to `to` (Shift+click / Shift+arrows). + pub fn extend_to(&mut self, to: CellRef) { + let r = RangeRef::new(self.anchor, to); + if let Some(last) = self.ranges.last_mut() { + *last = r; + } else { + self.ranges.push(r); + } + } + /// The A1 text of all areas (`A1:B2,D4`). + pub fn a1(&self) -> String { + self.ranges.iter().map(|r| r.a1()).collect::>().join(",") + } + /// Expands areas so merged cells are included whole. + pub fn expand_merges(&mut self, sheet: &Sheet) { + for r in self.ranges.iter_mut() { + let mut cur = *r; + loop { + let mut grown = cur; + for m in &sheet.merges { + if m.intersects(&grown) { + grown = grown.union(m); + } + } + if grown == cur { + break; + } + cur = grown; + } + *r = cur; + } + } + /// Moves the active cell within the selection (Enter/Tab inside a multi-cell selection). + pub fn cycle(&mut self, forward: bool, by_row: bool) { + let r = self.current(); + if r.is_single() && self.ranges.len() == 1 { + return; + } + let a = self.active; + let (mut row, mut col) = (a.row as i64, a.col as i64); + let step: i64 = if forward { 1 } else { -1 }; + if by_row { + col += step; + if col > r.end.col as i64 { + col = r.start.col as i64; + row += 1; + } else if col < r.start.col as i64 { + col = r.end.col as i64; + row -= 1; + } + if row > r.end.row as i64 { + row = r.start.row as i64; + } else if row < r.start.row as i64 { + row = r.end.row as i64; + } + } else { + row += step; + if row > r.end.row as i64 { + row = r.start.row as i64; + col += 1; + } else if row < r.start.row as i64 { + row = r.end.row as i64; + col -= 1; + } + if col > r.end.col as i64 { + col = r.start.col as i64; + } else if col < r.start.col as i64 { + col = r.end.col as i64; + } + } + self.active = CellRef::new(row.clamp(0, MAX_ROWS as i64 - 1) as u32, col.clamp(0, MAX_COLS as i64 - 1) as u32); + } +} + +/// Ctrl+arrow: jump to the edge of the current data region (Excel's End mode). +pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef { + let filled = |c: CellRef| sheet.value_ref(c).is_some_and(|v| !v.is_empty()); + let Some(mut cur) = from.offset(dr, dc) else { return from }; + let here = filled(from); + let next = filled(cur); + if here && next { + // Move to the last filled cell of the run. + while let Some(n) = cur.offset(dr, dc) { + if !filled(n) { + break; + } + cur = n; + } + return cur; + } + // Move to the next filled cell, or the sheet edge. Use the used range to avoid walking a + // million empty cells. + let used = sheet.used_range(); + loop { + if filled(cur) { + return cur; + } + let beyond = match used { + Some(u) => (dr > 0 && cur.row > u.end.row) || (dc > 0 && cur.col > u.end.col) || (dr < 0 && cur.row < u.start.row) || (dc < 0 && cur.col < u.start.col), + None => true, + }; + if beyond { + let r = if dr > 0 { MAX_ROWS - 1 } else if dr < 0 { 0 } else { cur.row }; + let c = if dc > 0 { MAX_COLS - 1 } else if dc < 0 { 0 } else { cur.col }; + return CellRef::new(r, c); + } + match cur.offset(dr, dc) { + Some(n) => cur = n, + None => return cur, + } + } +} + +/// The contiguous data region around `c` (Ctrl+A / Ctrl+Shift+8, AutoSum, sort, tables). +pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef { + let filled = |c: CellRef| sheet.value_ref(c).is_some_and(|v| !v.is_empty()); + let mut r = RangeRef::cell(c); + for _ in 0..10_000 { + let mut grown = r; + let r0 = r.start.row.saturating_sub(1); + let r1 = (r.end.row + 1).min(MAX_ROWS - 1); + let c0 = r.start.col.saturating_sub(1); + let c1 = (r.end.col + 1).min(MAX_COLS - 1); + // Check the ring around r. + let ring = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)); + let mut any = false; + for (cc, cell) in sheet.cells.iter_range(ring) { + if !r.contains(cc) && (!cell.value.is_empty() || cell.formula.is_some()) { + grown = grown.union(&RangeRef::cell(cc)); + any = true; + } + } + for (cc, _) in sheet.spill.range(CellRef::new(r0, 0)..=CellRef::new(r1, MAX_COLS - 1)) { + if ring.contains(*cc) && !r.contains(*cc) { + grown = grown.union(&RangeRef::cell(*cc)); + any = true; + } + } + if !any || grown == r { + break; + } + r = grown; + } + if r.is_single() && !filled(c) { + return RangeRef::cell(c); + } + r +} + +#[cfg(test)] +mod tests { + use super::*; + use sheetcraft_core::Value; + + #[test] + fn jumps() { + let mut s = Sheet::new("S"); + for r in 0..5 { + s.set_value(CellRef::new(r, 0), Value::Number(1.0)); + } + s.set_value(CellRef::new(10, 0), Value::Number(1.0)); + assert_eq!(jump(&s, CellRef::new(0, 0), 1, 0), CellRef::new(4, 0)); + assert_eq!(jump(&s, CellRef::new(4, 0), 1, 0), CellRef::new(10, 0)); + assert_eq!(jump(&s, CellRef::new(10, 0), 1, 0), CellRef::new(MAX_ROWS - 1, 0)); + assert_eq!(jump(&s, CellRef::new(0, 0), 0, 1), CellRef::new(0, MAX_COLS - 1)); + assert_eq!(jump(&s, CellRef::new(0, 0), -1, 0), CellRef::new(0, 0)); + } + + #[test] + fn regions() { + let mut s = Sheet::new("S"); + for r in 2..5 { + for c in 1..4 { + s.set_value(CellRef::new(r, c), Value::Number(1.0)); + } + } + assert_eq!(current_region(&s, CellRef::new(3, 2)).a1(), "B3:D5"); + assert_eq!(current_region(&s, CellRef::new(5, 4)).a1(), "B3:E6"); + assert_eq!(current_region(&s, CellRef::new(20, 20)).a1(), "U21"); + } + + #[test] + fn cycling() { + let mut sel = Selection::range(RangeRef::parse("A1:B2").unwrap()); + sel.cycle(true, false); + assert_eq!(sel.active.a1(), "A2"); + sel.cycle(true, false); + assert_eq!(sel.active.a1(), "B1"); + sel.cycle(true, true); + assert_eq!(sel.active.a1(), "A2"); + } +} diff --git a/crates/engine/src/tables.rs b/crates/engine/src/tables.rs new file mode 100644 index 00000000..f3bacfc0 --- /dev/null +++ b/crates/engine/src/tables.rs @@ -0,0 +1,148 @@ +//! Table styles: how a cell inside a table looks (our own palettes, keyed by the familiar +//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip). + +use sheetcraft_core::CellRef; +use sheetcraft_model::*; + +/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2). +pub fn parse_style_name(name: &str) -> (&'static str, u32) { + let rest = name.strip_prefix("TableStyle").unwrap_or(name); + for fam in ["Light", "Medium", "Dark"] { + if let Some(n) = rest.strip_prefix(fam) { + let k = n.parse().unwrap_or(2); + return (fam, k); + } + } + ("Medium", 2) +} + +/// Accent theme slot (4..=9) for a style number; 1 and every 7th style are neutral (dk1). +fn accent_for(n: u32) -> Option { + let k = (n.saturating_sub(1)) % 7; + if k == 0 { None } else { Some(3 + k as u8) } +} + +/// Header/band/body colours for a table style. +pub struct TableLook { + pub header_fill: Option, + pub header_font: Color, + pub header_bold: bool, + pub band_fill: Option, + pub body_fill: Option, + pub line: Option, + pub total_top: Option, +} + +pub fn look(style: &str) -> TableLook { + let (fam, n) = parse_style_name(style); + let acc = accent_for(n); + let base = |tint: i16| match acc { + Some(a) => Color::Theme(a, tint), + None => Color::Theme(1, if tint == 0 { 0 } else { tint.clamp(-1000, 1000) }), + }; + let neutral_light = |tint: i16| match acc { + Some(a) => Color::Theme(a, tint), + None => Color::Theme(0, -((1000 - tint).clamp(0, 1000) / 6)), + }; + match fam { + "Light" => { + let group = (n - 1) / 7; // 0: lines only, 1: banded header line, 2: grid + TableLook { + header_fill: None, + header_font: if group == 0 { base(-250) } else { Color::Auto }, + header_bold: true, + band_fill: Some(neutral_light(800)), + body_fill: None, + line: Some(base(0)), + total_top: Some(base(0)), + } + } + "Dark" => TableLook { + header_fill: Some(Color::Theme(1, 0)), + header_font: Color::Theme(0, 0), + header_bold: true, + band_fill: Some(base(-250)), + body_fill: Some(base(0)), + line: None, + total_top: Some(Color::Theme(0, 0)), + }, + _ => TableLook { + header_fill: Some(base(0)), + header_font: Color::Theme(0, 0), + header_bold: true, + band_fill: Some(neutral_light(800)), + body_fill: None, + line: if (n - 1) / 7 == 0 { None } else { Some(base(400)) }, + total_top: Some(base(0)), + }, + } +} + +/// The style a table gives a cell (merged over the cell's own style: explicit fills/fonts win). +pub fn table_cell_style(wb: &Workbook, sheet: usize, t: &Table, c: CellRef) -> Style { + let own = wb.sheet(sheet).map(|sh| wb.styles.get(sh.style_id(c)).clone()).unwrap_or_default(); + let mut s = own.clone(); + let lk = look(&t.style); + let is_header = t.header_row && c.row == t.range.start.row; + let is_total = t.totals_row && c.row == t.range.end.row; + let data_row = c.row.saturating_sub(t.range.start.row + t.header_row as u32); + let data_col = c.col - t.range.start.col; + let explicit_fill = own.fill.pattern != PatternType::None; + if is_header { + if let Some(f) = lk.header_fill + && !explicit_fill + { + s.fill = Fill::solid(f); + } + if own.font.color == Color::Auto { + s.font.color = lk.header_font; + } + s.font.bold = s.font.bold || lk.header_bold; + if let Some(l) = lk.line { + s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: l }; + } + } else if is_total { + s.font.bold = true; + if let Some(l) = lk.total_top { + s.borders.top = BorderLine { style: BorderStyle::Double, color: l }; + } + } else { + let band = (t.banded_rows && data_row.is_multiple_of(2)) || (t.banded_cols && data_col.is_multiple_of(2)); + if !explicit_fill { + if band { + if let Some(f) = lk.band_fill { + s.fill = Fill::solid(f); + } + } else if let Some(f) = lk.body_fill { + s.fill = Fill::solid(f); + } + } + if let Some(l) = lk.line + && t.style.contains("Light") + && c.row == t.range.end.row { + s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: l }; + } + } + if (t.first_col && c.col == t.range.start.col) || (t.last_col && c.col == t.range.end.col) { + s.font.bold = true; + } + if t.style.starts_with("TableStyleDark") && !is_header && own.font.color == Color::Auto { + s.font.color = Color::Theme(0, 0); + } + s +} + +/// Gallery list of style names. +pub fn gallery() -> Vec { + let mut v = Vec::new(); + for n in 1..=21 { + v.push(format!("TableStyleLight{n}")); + } + for n in 1..=28 { + v.push(format!("TableStyleMedium{n}")); + } + for n in 1..=11 { + v.push(format!("TableStyleDark{n}")); + } + v +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs new file mode 100644 index 00000000..994e063b --- /dev/null +++ b/crates/engine/src/tests.rs @@ -0,0 +1,175 @@ +use serde_json::json; +use sheetcraft_core::{CellRef, Value}; + +use crate::Session; + +fn s() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn v(s: &Session, a: &str) -> Value { + s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) +} + +#[test] +fn enter_values_and_formulas() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "32"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "=sum(a1:a2"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Number(42.0)); + assert!(s.execute("cell.set", json!({"cell": "A4", "input": "=1+*2"})).is_err()); + s.execute("cell.set", json!({"cell": "B1", "input": "12%"})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("B1").unwrap()), "12%"); +} + +#[test] +fn undo_redo() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(2.0)); + assert!(s.doc().unwrap().is_dirty()); +} + +#[test] +fn copy_paste_shifts_formulas() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A1+B1"})).unwrap(); + s.execute("edit.copy", json!({"range": "C1"})).unwrap(); + s.execute("selection.set", json!({"range": "C2"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(v(&s, "C2"), Value::Number(7.0)); + s.execute("edit.cut", json!({"range": "A1"})).unwrap(); + s.execute("selection.set", json!({"range": "E5"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(v(&s, "E5"), Value::Number(1.0)); + let f = s.execute("cell.get", json!({"cell": "C1"})).unwrap(); + assert_eq!(f["formula"], "=E5+B1"); +} + +#[test] +fn insert_delete_rows_adjust() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A3)"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "2:2"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["formula"], "=SUM(A1:A4)"); + s.execute("cell.set", json!({"cell": "A2", "input": "10"})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Number(16.0)); + s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Empty); + assert_eq!(v(&s, "A1"), Value::Number(10.0)); +} + +#[test] +fn fill_series() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [3]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "A1:A2", "target": "A1:A6"})).unwrap(); + assert_eq!(v(&s, "A6"), Value::Number(11.0)); + s.execute("cell.set", json!({"cell": "B1", "input": "Mon"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3"})).unwrap(); + assert_eq!(v(&s, "B3"), Value::from("Wed")); + s.execute("cell.set", json!({"cell": "C1", "input": "Item 9"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "C1", "target": "C1:C2"})).unwrap(); + assert_eq!(v(&s, "C2"), Value::from("Item 10")); +} + +#[test] +fn sort_and_filter() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["b", 2], ["c", 3], ["a", 1]]})).unwrap(); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + s.execute("data.sortDescending", json!({})).unwrap(); + assert_eq!(v(&s, "A2"), Value::from("c")); + assert_eq!(v(&s, "A1"), Value::from("Name")); + s.execute("data.filter", json!({})).unwrap(); + let r = s.execute("data.filterBy", json!({"column": "B", "custom": {"op": ">", "value": "1"}})).unwrap(); + assert_eq!(r["hiddenRows"], 1); +} + +#[test] +fn formatting_and_styles() { + let mut s = s(); + s.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); + s.execute("home.bold", json!({})).unwrap(); + s.execute("home.fillColor", json!({"color": "#FFFF00"})).unwrap(); + s.execute("home.mergeCenter", json!({})).unwrap(); + let c = s.execute("cell.get", json!({"cell": "B2"})).unwrap(); + assert_eq!(c["style"]["font"]["bold"], true); + assert_eq!(c["merge"], "A1:B2"); + s.execute("home.cellStyle", json!({"range": "D1", "name": "Good"})).unwrap(); +} + +#[test] +fn tables_and_structured_refs() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty"], ["x", 2], ["y", 5]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B3"})).unwrap(); + s.execute("cell.set", json!({"cell": "D1", "input": "=SUM(Table1[Qty])"})).unwrap(); + assert_eq!(v(&s, "D1"), Value::Number(7.0)); + s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap(); + assert_eq!(v(&s, "B4"), Value::Number(7.0)); +} + +#[test] +fn sheets() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("sheet.rename", json!({"name": "Data"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "=Data!A1*2"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(10.0)); + s.execute("sheet.rename", json!({"sheet": "Data", "name": "My Data"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "='My Data'!A1*2"); +} + +#[test] +fn xlsx_roundtrip_through_engine() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["a", 1], ["b", 2]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": "=SUM(B1:B2)"})).unwrap(); + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(v(&s, "B3"), Value::Number(3.0)); +} + +#[test] +fn samples_build() { + for (name, _) in crate::sample::SAMPLES { + let wb = crate::sample::build(name).unwrap_or_else(|| panic!("sample {name}")); + assert!(!wb.sheets[0].cells.is_empty()); + } +} + +#[test] +fn every_command_survives_empty_params() { + let mut s = Session::new(); + s.execute("file.new", json!({"sample": "sales"})).unwrap(); + for spec in crate::command_specs() { + if matches!(spec.id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture") { + continue; + } + let _ = s.execute(spec.id, json!({})); + let _ = s.execute(spec.id, json!([1, 2])); + let _ = s.execute(spec.id, json!({"range": "ZZZ999999999", "cell": "", "sheet": 99})); + } +} + +#[test] +fn parity_counts() { + let (done, total) = crate::catalog::parity(); + assert!(total > 250); + assert!(done > 150, "parity {done}/{total}"); +} diff --git a/crates/formula/Cargo.toml b/crates/formula/Cargo.toml new file mode 100644 index 00000000..2e9692d5 --- /dev/null +++ b/crates/formula/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "sheetcraft-formula" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft formula language: lexer, parser, printer and reference adjustment" + +[lints] +workspace = true + +[dependencies] +sheetcraft-core = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs new file mode 100644 index 00000000..7cef1068 --- /dev/null +++ b/crates/formula/src/adjust.rs @@ -0,0 +1,198 @@ +//! Reference adjustment: copying formulas, inserting/deleting rows and columns, moving cells, +//! renaming and deleting sheets. + +use sheetcraft_core::{CellError, MAX_COLS, MAX_ROWS, RangeRef}; + +use crate::ast::*; + +/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`. +pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr { + e.map(&mut |x| match x { + Expr::Ref(r) => match shift_ref(&r, dr, dc) { + Some(r) => Expr::Ref(r), + None => Expr::Error(CellError::Ref), + }, + other => other, + }) +} + +fn shift_anchor(a: &Anchor, dr: i64, dc: i64) -> Option { + let row = if a.row_abs { a.row as i64 } else { a.row as i64 + dr }; + let col = if a.col_abs { a.col as i64 } else { a.col as i64 + dc }; + let (row, col) = clamp_anchor(row, col)?; + Some(Anchor { row, col, ..*a }) +} + +fn shift_line(v: u32, abs: bool, d: i64, max: u32) -> Option { + if abs { + return Some(v); + } + let n = v as i64 + d; + if (0..max as i64).contains(&n) { Some(n as u32) } else { None } +} + +fn shift_ref(r: &Reference, dr: i64, dc: i64) -> Option { + let kind = match &r.kind { + RefKind::Cell(a) => RefKind::Cell(shift_anchor(a, dr, dc)?), + RefKind::Range(a, b) => RefKind::Range(shift_anchor(a, dr, dc)?, shift_anchor(b, dr, dc)?), + RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(shift_line(*r0, *a0, dr, MAX_ROWS)?, *a0, shift_line(*r1, *a1, dr, MAX_ROWS)?, *a1), + RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(shift_line(*c0, *a0, dc, MAX_COLS)?, *a0, shift_line(*c1, *a1, dc, MAX_COLS)?, *a1), + }; + Some(Reference { sheet: r.sheet.clone(), kind }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Axis { + Rows, + Cols, +} + +/// A structural edit on one sheet. +#[derive(Clone, Debug, PartialEq)] +pub enum Edit { + /// Insert `count` rows/cols before index `at`. + Insert { axis: Axis, at: u32, count: u32 }, + /// Delete `count` rows/cols starting at `at`. + Delete { axis: Axis, at: u32, count: u32 }, + /// Cut-paste of a block: references into `from` now point into the block at `to` (top-left). + Move { from: RangeRef, to_row: u32, to_col: u32 }, +} + +/// Adjusts a formula living on sheet `host` after `edit` on sheet `target`. +pub fn adjust(e: Expr, host: &str, target: &str, edit: &Edit) -> Expr { + e.map(&mut |x| match x { + Expr::Ref(r) => { + let on_target = match &r.sheet { + SheetSel::Current => host.eq_ignore_ascii_case(target), + SheetSel::Named(n) => n.eq_ignore_ascii_case(target), + SheetSel::Span(..) => false, + }; + if !on_target { + return Expr::Ref(r); + } + match adjust_ref(&r, edit) { + Some(r) => Expr::Ref(r), + None => Expr::Error(CellError::Ref), + } + } + other => other, + }) +} + +/// New position of an index after an insert (`None` never) or delete (`None` = deleted). +fn map_index(v: u32, at: u32, count: u32, insert: bool, max: u32) -> Option { + if insert { + if v >= at { + let n = v as u64 + count as u64; + if n >= max as u64 { None } else { Some(n as u32) } + } else { + Some(v) + } + } else if v < at { + Some(v) + } else if v >= at.saturating_add(count) { + Some(v - count) + } else { + None + } +} + +/// Adjusts a span `[lo, hi]` (inclusive) on one axis. +fn map_span(lo: u32, hi: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<(u32, u32)> { + if insert { + let lo2 = if lo >= at { lo as u64 + count as u64 } else { lo as u64 }; + let hi2 = if hi >= at { hi as u64 + count as u64 } else { hi as u64 }; + // Whole-sheet spans (e.g. A:A) stay whole. + let hi2 = hi2.min(max as u64 - 1); + if lo2 > hi2 { + return None; + } + Some((lo2 as u32, hi2 as u32)) + } else { + let end = at.saturating_add(count); // exclusive + if lo >= at && hi < end { + return None; // fully deleted + } + let lo2 = if lo < at { lo } else if lo >= end { lo - count } else { at }; + let hi2 = if hi < at { hi } else if hi >= end { hi - count } else { at.saturating_sub(1) }; + Some((lo2, hi2.max(lo2))) + } +} + +fn adjust_ref(r: &Reference, edit: &Edit) -> Option { + match edit { + Edit::Insert { axis, at, count } | Edit::Delete { axis, at, count } => { + let insert = matches!(edit, Edit::Insert { .. }); + let kind = match (&r.kind, axis) { + (RefKind::Cell(a), Axis::Rows) => RefKind::Cell(Anchor { row: map_index(a.row, *at, *count, insert, MAX_ROWS)?, ..*a }), + (RefKind::Cell(a), Axis::Cols) => RefKind::Cell(Anchor { col: map_index(a.col, *at, *count, insert, MAX_COLS)?, ..*a }), + (RefKind::Range(a, b), Axis::Rows) => { + let (lo, hi) = map_span(a.row.min(b.row), a.row.max(b.row), *at, *count, insert, MAX_ROWS)?; + RefKind::Range(Anchor { row: lo, ..*a }, Anchor { row: hi, ..*b }) + } + (RefKind::Range(a, b), Axis::Cols) => { + let (lo, hi) = map_span(a.col.min(b.col), a.col.max(b.col), *at, *count, insert, MAX_COLS)?; + RefKind::Range(Anchor { col: lo, ..*a }, Anchor { col: hi, ..*b }) + } + (RefKind::Rows(r0, a0, r1, a1), Axis::Rows) => { + let (lo, hi) = map_span(*r0.min(r1), *r0.max(r1), *at, *count, insert, MAX_ROWS)?; + RefKind::Rows(lo, *a0, hi, *a1) + } + (RefKind::Cols(c0, a0, c1, a1), Axis::Cols) => { + let (lo, hi) = map_span(*c0.min(c1), *c0.max(c1), *at, *count, insert, MAX_COLS)?; + RefKind::Cols(lo, *a0, hi, *a1) + } + (k, _) => k.clone(), + }; + Some(Reference { sheet: r.sheet.clone(), kind }) + } + Edit::Move { from, to_row, to_col } => { + let dr = *to_row as i64 - from.start.row as i64; + let dc = *to_col as i64 - from.start.col as i64; + let range = r.range(); + if !from.contains_range(&range) { + return Some(r.clone()); + } + let mv = |a: &Anchor| -> Option { + let (row, col) = clamp_anchor(a.row as i64 + dr, a.col as i64 + dc)?; + Some(Anchor { row, col, ..*a }) + }; + let kind = match &r.kind { + RefKind::Cell(a) => RefKind::Cell(mv(a)?), + RefKind::Range(a, b) => RefKind::Range(mv(a)?, mv(b)?), + k => k.clone(), + }; + Some(Reference { sheet: r.sheet.clone(), kind }) + } + } +} + +/// Renames sheet references `old` → `new`. +pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr { + e.map(&mut |x| match x { + Expr::Ref(mut r) => { + match &mut r.sheet { + SheetSel::Named(n) if n.eq_ignore_ascii_case(old) => *n = new.to_string(), + SheetSel::Span(a, b) => { + if a.eq_ignore_ascii_case(old) { + *a = new.to_string(); + } + if b.eq_ignore_ascii_case(old) { + *b = new.to_string(); + } + } + _ => {} + } + Expr::Ref(r) + } + other => other, + }) +} + +/// References to a deleted sheet become `#REF!`. +pub fn delete_sheet(e: Expr, name: &str) -> Expr { + e.map(&mut |x| match x { + Expr::Ref(r) if r.sheet_name().is_some_and(|n| n.eq_ignore_ascii_case(name)) => Expr::Error(CellError::Ref), + other => other, + }) +} diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs new file mode 100644 index 00000000..c8b45927 --- /dev/null +++ b/crates/formula/src/ast.rs @@ -0,0 +1,229 @@ +//! Formula syntax tree. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use sheetcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef}; + +/// One end of a reference, with `$` anchors. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Anchor { + pub row: u32, + pub col: u32, + pub row_abs: bool, + pub col_abs: bool, +} + +impl Anchor { + pub fn cell(&self) -> CellRef { + CellRef::new(self.row, self.col) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum RefKind { + Cell(Anchor), + Range(Anchor, Anchor), + /// Whole rows `3:5` (row, abs) pairs. + Rows(u32, bool, u32, bool), + /// Whole columns `A:C`. + Cols(u32, bool, u32, bool), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SheetSel { + /// Unqualified: the formula's own sheet. + Current, + Named(String), + /// 3-D span `Sheet1:Sheet3`. + Span(String, String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Reference { + pub sheet: SheetSel, + pub kind: RefKind, +} + +impl Reference { + pub fn range(&self) -> RangeRef { + match &self.kind { + RefKind::Cell(a) => RangeRef::cell(a.cell()), + RefKind::Range(a, b) => RangeRef::new(a.cell(), b.cell()), + RefKind::Rows(r0, _, r1, _) => RangeRef::rows((*r0).min(*r1), (*r0).max(*r1)), + RefKind::Cols(c0, _, c1, _) => RangeRef::cols((*c0).min(*c1), (*c0).max(*c1)), + } + } + pub fn sheet_name(&self) -> Option<&str> { + match &self.sheet { + SheetSel::Named(s) => Some(s), + _ => None, + } + } +} + +/// Structured reference `Table1[[#Headers],[Col1]:[Col3]]`, `[@Col]`. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct StructRef { + /// Empty for `[@Col]` inside the table itself. + pub table: String, + pub specifiers: Vec, + pub col_start: Option, + pub col_end: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum StructItem { + All, + Data, + Headers, + Totals, + ThisRow, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum UnOp { + Neg, + Plus, + Percent, + /// Implicit intersection `@`. + At, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum BinOp { + Add, + Sub, + Mul, + Div, + Pow, + Concat, + Eq, + Ne, + Lt, + Gt, + Le, + Ge, + /// `:` between two references. + Range, + /// `,` inside parentheses. + Union, + /// space between two references. + Intersect, +} + +impl BinOp { + pub fn symbol(&self) -> &'static str { + match self { + BinOp::Add => "+", + BinOp::Sub => "-", + BinOp::Mul => "*", + BinOp::Div => "/", + BinOp::Pow => "^", + BinOp::Concat => "&", + BinOp::Eq => "=", + BinOp::Ne => "<>", + BinOp::Lt => "<", + BinOp::Gt => ">", + BinOp::Le => "<=", + BinOp::Ge => ">=", + BinOp::Range => ":", + BinOp::Union => ",", + BinOp::Intersect => " ", + } + } + /// Binding power (higher binds tighter). + pub fn precedence(&self) -> u8 { + match self { + BinOp::Range => 90, + BinOp::Intersect => 85, + BinOp::Union => 80, + BinOp::Pow => 50, + BinOp::Mul | BinOp::Div => 40, + BinOp::Add | BinOp::Sub => 30, + BinOp::Concat => 20, + BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => 10, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Expr { + Number(f64), + Text(Arc), + Bool(bool), + Error(CellError), + /// Array constant `{1,2;3,4}` (rows of literal expressions). + Array(Vec>), + Ref(Reference), + /// Defined name, table name or LET/LAMBDA parameter. + Name(String), + Struct(StructRef), + Unary(UnOp, Box), + Binary(BinOp, Box, Box), + /// Function call; the name is upper-case without `_xlfn.`. + Call(String, Vec), + /// Calling the result of an expression, e.g. `LAMBDA(x,x+1)(2)`. + Invoke(Box, Vec), + /// An omitted argument `F(1,,3)`. + Missing, + Paren(Box), +} + +impl Expr { + /// Visits every node depth-first. + pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&'a Expr)) { + f(self); + match self { + Expr::Array(rows) => rows.iter().flatten().for_each(|e| e.walk(f)), + Expr::Unary(_, e) | Expr::Paren(e) => e.walk(f), + Expr::Binary(_, a, b) => { + a.walk(f); + b.walk(f); + } + Expr::Call(_, args) => args.iter().for_each(|e| e.walk(f)), + Expr::Invoke(c, args) => { + c.walk(f); + args.iter().for_each(|e| e.walk(f)); + } + _ => {} + } + } + /// Rewrites every node bottom-up. + pub fn map(self, f: &mut dyn FnMut(Expr) -> Expr) -> Expr { + let e = match self { + Expr::Array(rows) => Expr::Array(rows.into_iter().map(|r| r.into_iter().map(|e| e.map(f)).collect()).collect()), + Expr::Unary(op, e) => Expr::Unary(op, Box::new(e.map(f))), + Expr::Paren(e) => Expr::Paren(Box::new(e.map(f))), + Expr::Binary(op, a, b) => Expr::Binary(op, Box::new(a.map(f)), Box::new(b.map(f))), + Expr::Call(n, args) => Expr::Call(n, args.into_iter().map(|e| e.map(f)).collect()), + Expr::Invoke(c, args) => Expr::Invoke(Box::new(c.map(f)), args.into_iter().map(|e| e.map(f)).collect()), + other => other, + }; + f(e) + } + /// All references in the formula. + pub fn references(&self) -> Vec<&Reference> { + let mut v = Vec::new(); + self.walk(&mut |e| { + if let Expr::Ref(r) = e { + v.push(r); + } + }); + v + } + /// Function names used (upper-case). + pub fn functions(&self) -> Vec<&str> { + let mut v = Vec::new(); + self.walk(&mut |e| { + if let Expr::Call(n, _) = e { + v.push(n.as_str()); + } + }); + v + } +} + +pub(crate) fn clamp_anchor(row: i64, col: i64) -> Option<(u32, u32)> { + if (0..MAX_ROWS as i64).contains(&row) && (0..MAX_COLS as i64).contains(&col) { Some((row as u32, col as u32)) } else { None } +} diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs new file mode 100644 index 00000000..1e24a258 --- /dev/null +++ b/crates/formula/src/lexer.rs @@ -0,0 +1,312 @@ +//! Tokenizer for Excel formulas (A1 style, en-US separators). +//! +//! Token spans are byte offsets into the source, which the editor uses to colour references. + +use sheetcraft_core::CellError; + +#[derive(Clone, Debug, PartialEq)] +pub enum Tok { + Number(f64), + Text(String), + Bool(bool), + Error(CellError), + /// A reference-looking or name-looking word, possibly sheet-qualified: `A1`, `$B$2`, `A:A` + /// is lexed as Word(A) Colon Word(A); `Sheet1!A1` → `Sheet(Some("Sheet1"))` then `Word`. + Word(String), + /// Sheet prefix `Sheet1!`, `'My Sheet'!`, `Sheet1:Sheet3!` (first, last). + Sheet(String, Option), + /// Function name (word immediately followed by `(`), the `(` is consumed. + Func(String), + /// Structured reference body after a table name or bare: the raw text inside `[...]`. + Struct(String), + Op(&'static str), + LParen, + RParen, + LBrace, + RBrace, + Comma, + Semicolon, + Colon, + Space, + Eof, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Token { + pub tok: Tok, + pub start: usize, + pub end: usize, +} + +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +#[error("{msg} at {pos}")] +pub struct LexError { + pub msg: String, + pub pos: usize, +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '_' | '.' | '$' | '\\' | '?') +} + +/// Tokenizes formula text (without the leading `=`). +pub fn tokenize(src: &str) -> Result, LexError> { + let chars: Vec<(usize, char)> = src.char_indices().collect(); + let n = chars.len(); + let pos_of = |i: usize| chars.get(i).map(|c| c.0).unwrap_or(src.len()); + let ch = |i: usize| chars.get(i).map(|c| c.1); + let mut out = Vec::new(); + let mut i = 0; + while i < n { + let start = pos_of(i); + let Some(c) = ch(i) else { break }; + let push = |out: &mut Vec, tok: Tok, end_i: usize| out.push(Token { tok, start, end: pos_of(end_i) }); + match c { + ' ' | '\t' | '\n' | '\r' => { + let mut j = i; + while matches!(ch(j), Some(' ' | '\t' | '\n' | '\r')) { + j += 1; + } + push(&mut out, Tok::Space, j); + i = j; + } + '"' => { + let mut s = String::new(); + let mut j = i + 1; + loop { + match ch(j) { + None => return Err(LexError { msg: "unterminated text".into(), pos: start }), + Some('"') if ch(j + 1) == Some('"') => { + s.push('"'); + j += 2; + } + Some('"') => { + j += 1; + break; + } + Some(x) => { + s.push(x); + j += 1; + } + } + } + push(&mut out, Tok::Text(s), j); + i = j; + } + '\'' => { + // Quoted sheet name: 'My Sheet'! or 'A:B'! + let mut s = String::new(); + let mut j = i + 1; + loop { + match ch(j) { + None => return Err(LexError { msg: "unterminated sheet name".into(), pos: start }), + Some('\'') if ch(j + 1) == Some('\'') => { + s.push('\''); + j += 2; + } + Some('\'') => { + j += 1; + break; + } + Some(x) => { + s.push(x); + j += 1; + } + } + } + if ch(j) != Some('!') { + return Err(LexError { msg: "expected ! after sheet name".into(), pos: pos_of(j) }); + } + let (a, b) = match s.split_once(':') { + Some((a, b)) => (a.to_string(), Some(b.to_string())), + None => (s, None), + }; + push(&mut out, Tok::Sheet(a, b), j + 1); + i = j + 1; + } + '#' => { + // Error literal, or #-prefixed struct specifier (only inside [] which we lex as Struct). + let rest = src.get(start..).unwrap_or(""); + let mut found = None; + for e in CellError::ALL { + let t = e.as_str(); + if rest.len() >= t.len() && rest.get(..t.len()).is_some_and(|p| p.eq_ignore_ascii_case(t)) { + found = Some(e); + break; + } + } + let Some(e) = found else { return Err(LexError { msg: "unknown error literal".into(), pos: start }) }; + let len = e.as_str().chars().count(); + // `#REF!A1` style is not valid; `#REF!` is an error literal. + push(&mut out, Tok::Error(e), i + len); + i += len; + } + '[' => { + // Structured reference body: balanced brackets. + let mut depth = 0; + let mut j = i; + loop { + match ch(j) { + None => return Err(LexError { msg: "unbalanced [".into(), pos: start }), + Some('\'') => j += 2, // escape char inside struct refs + Some('[') => { + depth += 1; + j += 1; + } + Some(']') => { + depth -= 1; + j += 1; + if depth == 0 { + break; + } + } + Some(_) => j += 1, + } + } + let inner = src.get(pos_of(i + 1)..pos_of(j.saturating_sub(1))).unwrap_or("").to_string(); + push(&mut out, Tok::Struct(inner), j); + i = j; + } + '0'..='9' | '.' if c != '.' || ch(i + 1).is_some_and(|d| d.is_ascii_digit()) => { + // Number, unless it is a row range like 1:3 (handled by parser via Word) — numbers + // followed by ':' and digits are row refs: lex as Word. + let mut j = i; + while ch(j).is_some_and(|d| d.is_ascii_digit()) { + j += 1; + } + let is_row_ref = ch(j) == Some(':') && { + let mut k = j + 1; + if ch(k) == Some('$') { + k += 1; + } + ch(k).is_some_and(|d| d.is_ascii_digit()) + }; + // A word like "1A" can't happen in valid formulas; numbers then. + if is_row_ref { + let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); + push(&mut out, Tok::Word(w), j); + i = j; + continue; + } + if ch(j) == Some('.') { + j += 1; + while ch(j).is_some_and(|d| d.is_ascii_digit()) { + j += 1; + } + } + if matches!(ch(j), Some('e' | 'E')) { + let mut k = j + 1; + if matches!(ch(k), Some('+' | '-')) { + k += 1; + } + if ch(k).is_some_and(|d| d.is_ascii_digit()) { + while ch(k).is_some_and(|d| d.is_ascii_digit()) { + k += 1; + } + j = k; + } + } + let text = src.get(start..pos_of(j)).unwrap_or(""); + let v: f64 = text.parse().map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?; + push(&mut out, Tok::Number(v), j); + i = j; + } + '(' => { + push(&mut out, Tok::LParen, i + 1); + i += 1; + } + ')' => { + push(&mut out, Tok::RParen, i + 1); + i += 1; + } + '{' => { + push(&mut out, Tok::LBrace, i + 1); + i += 1; + } + '}' => { + push(&mut out, Tok::RBrace, i + 1); + i += 1; + } + ',' => { + push(&mut out, Tok::Comma, i + 1); + i += 1; + } + ';' => { + push(&mut out, Tok::Semicolon, i + 1); + i += 1; + } + ':' => { + push(&mut out, Tok::Colon, i + 1); + i += 1; + } + '+' | '-' | '*' | '/' | '^' | '&' | '%' | '@' | '=' => { + let op = match c { + '+' => "+", + '-' => "-", + '*' => "*", + '/' => "/", + '^' => "^", + '&' => "&", + '%' => "%", + '@' => "@", + _ => "=", + }; + push(&mut out, Tok::Op(op), i + 1); + i += 1; + } + '<' | '>' => { + let two = ch(i + 1); + let (op, len) = match (c, two) { + ('<', Some('=')) => ("<=", 2), + ('<', Some('>')) => ("<>", 2), + ('>', Some('=')) => (">=", 2), + ('<', _) => ("<", 1), + _ => (">", 1), + }; + push(&mut out, Tok::Op(op), i + len); + i += len; + } + c if is_word_char(c) => { + let mut j = i; + while ch(j).is_some_and(is_word_char) { + j += 1; + } + let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); + if ch(j) == Some('!') { + push(&mut out, Tok::Sheet(w, None), j + 1); + i = j + 1; + } else if ch(j) == Some(':') && { + // Sheet1:Sheet3!A1 — look ahead for word then '!' + let mut k = j + 1; + while ch(k).is_some_and(is_word_char) { + k += 1; + } + k > j + 1 && ch(k) == Some('!') + } { + let mut k = j + 1; + while ch(k).is_some_and(is_word_char) { + k += 1; + } + let w2: String = chars.get(j + 1..k).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); + push(&mut out, Tok::Sheet(w, Some(w2)), k + 1); + i = k + 1; + } else if ch(j) == Some('(') { + push(&mut out, Tok::Func(w), j + 1); + i = j + 1; + } else if w.eq_ignore_ascii_case("TRUE") && ch(j) != Some('[') { + push(&mut out, Tok::Bool(true), j); + i = j; + } else if w.eq_ignore_ascii_case("FALSE") && ch(j) != Some('[') { + push(&mut out, Tok::Bool(false), j); + i = j; + } else { + push(&mut out, Tok::Word(w), j); + i = j; + } + } + other => return Err(LexError { msg: format!("unexpected character `{other}`"), pos: start }), + } + } + out.push(Token { tok: Tok::Eof, start: src.len(), end: src.len() }); + Ok(out) +} diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs new file mode 100644 index 00000000..c929081d --- /dev/null +++ b/crates/formula/src/lib.rs @@ -0,0 +1,17 @@ +//! The Excel formula language: tokenizer, parser, syntax tree, printer (A1 and R1C1) and +//! reference adjustment for copy/fill and structural edits. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod adjust; +pub mod ast; +pub mod lexer; +pub mod parser; +pub mod printer; + +pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp}; +pub use parser::{ParseError, normalize_function_name, parse}; +pub use printer::{print, print_r1c1, quote_sheet}; + +#[cfg(test)] +mod tests; diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs new file mode 100644 index 00000000..00d86636 --- /dev/null +++ b/crates/formula/src/parser.rs @@ -0,0 +1,450 @@ +//! Pratt parser: tokens → [`Expr`]. + +use std::sync::Arc; + +use sheetcraft_core::addr::{letters_to_col, parse_a1_prefix}; +use sheetcraft_core::{CellError, MAX_ROWS}; + +use crate::ast::*; +use crate::lexer::{Tok, Token, tokenize}; + +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +#[error("{msg}")] +pub struct ParseError { + pub msg: String, + pub pos: usize, +} + +const MAX_DEPTH: usize = 256; + +/// Parses formula text. A leading `=` (or `+`/`-` as Lotus users type) is accepted and skipped +/// for `=`. +pub fn parse(src: &str) -> Result { + let body = src.strip_prefix('=').unwrap_or(src); + let toks = tokenize(body).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; + let toks = significant_spaces(toks); + let mut p = Parser { toks, i: 0, depth: 0 }; + let e = p.expr(0, false)?; + match p.peek() { + Tok::Eof => Ok(e), + t => Err(p.err(format!("unexpected {}", describe(t)))), + } +} + +fn describe(t: &Tok) -> String { + match t { + Tok::RParen => "`)`".into(), + Tok::Comma => "`,`".into(), + Tok::Eof => "end of formula".into(), + other => format!("{other:?}"), + } +} + +/// Keeps a space token only where it is the intersection operator: between the end of an +/// operand and the start of another. +fn significant_spaces(toks: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(toks.len()); + for (i, t) in toks.iter().enumerate() { + if t.tok == Tok::Space { + let prev_end = out.last().is_some_and(|p| matches!(p.tok, Tok::Word(_) | Tok::RParen | Tok::Struct(_))); + let next_start = toks.get(i + 1).is_some_and(|n| matches!(n.tok, Tok::Word(_) | Tok::Sheet(..) | Tok::LParen | Tok::Func(_))); + if prev_end && next_start { + out.push(t.clone()); + } + continue; + } + out.push(t.clone()); + } + out +} + +struct Parser { + toks: Vec, + i: usize, + depth: usize, +} + +fn word_is_cols(w: &str) -> Option<(u32, bool)> { + let abs = w.starts_with('$'); + letters_to_col(w.trim_start_matches('$')).map(|c| (c, abs)) +} + +fn word_is_row(w: &str) -> Option<(u32, bool)> { + let abs = w.starts_with('$'); + let n: u32 = w.trim_start_matches('$').parse().ok()?; + if n == 0 || n > MAX_ROWS { None } else { Some((n - 1, abs)) } +} + +fn word_is_cell(w: &str) -> Option { + let (col, row, col_abs, row_abs, rest) = parse_a1_prefix(w)?; + if !rest.is_empty() { + return None; + } + Some(Anchor { row, col, row_abs, col_abs }) +} + +/// `_xlfn.XLOOKUP` → `XLOOKUP`. +pub fn normalize_function_name(name: &str) -> String { + let u = name.to_ascii_uppercase(); + for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS.", "_XLUDF."] { + if let Some(r) = u.strip_prefix(p) { + return r.to_string(); + } + } + u +} + +impl Parser { + fn peek(&self) -> &Tok { + self.toks.get(self.i).map(|t| &t.tok).unwrap_or(&Tok::Eof) + } + fn peek_at(&self, k: usize) -> &Tok { + self.toks.get(self.i + k).map(|t| &t.tok).unwrap_or(&Tok::Eof) + } + fn pos(&self) -> usize { + self.toks.get(self.i).map(|t| t.start).unwrap_or(0) + } + fn next(&mut self) -> Tok { + let t = self.peek().clone(); + if self.i < self.toks.len() { + self.i += 1; + } + t + } + fn err(&self, msg: impl Into) -> ParseError { + ParseError { msg: msg.into(), pos: self.pos() } + } + fn expect(&mut self, t: Tok) -> Result<(), ParseError> { + if *self.peek() == t { + self.next(); + Ok(()) + } else { + Err(self.err(format!("expected {}, found {}", describe(&t), describe(self.peek())))) + } + } + + fn binop(&self, allow_union: bool) -> Option { + Some(match self.peek() { + Tok::Op("+") => BinOp::Add, + Tok::Op("-") => BinOp::Sub, + Tok::Op("*") => BinOp::Mul, + Tok::Op("/") => BinOp::Div, + Tok::Op("^") => BinOp::Pow, + Tok::Op("&") => BinOp::Concat, + Tok::Op("=") => BinOp::Eq, + Tok::Op("<>") => BinOp::Ne, + Tok::Op("<") => BinOp::Lt, + Tok::Op(">") => BinOp::Gt, + Tok::Op("<=") => BinOp::Le, + Tok::Op(">=") => BinOp::Ge, + Tok::Colon => BinOp::Range, + Tok::Space => BinOp::Intersect, + Tok::Comma if allow_union => BinOp::Union, + _ => return None, + }) + } + + fn expr(&mut self, min_bp: u8, allow_union: bool) -> Result { + self.depth += 1; + if self.depth > MAX_DEPTH { + return Err(self.err("formula is nested too deeply")); + } + let mut lhs = self.prefix(allow_union)?; + loop { + if *self.peek() == Tok::Op("%") { + if 70 < min_bp { + break; + } + self.next(); + lhs = Expr::Unary(UnOp::Percent, Box::new(lhs)); + continue; + } + if *self.peek() == Tok::LParen && matches!(lhs, Expr::Call(..) | Expr::Invoke(..) | Expr::Paren(_) | Expr::Name(_)) { + // Invoking a LAMBDA result. + if let Expr::Name(_) = lhs { + // A name followed by '(' was lexed as Func; this is `(name)(…)` only. + } + self.next(); + let args = self.args()?; + lhs = Expr::Invoke(Box::new(lhs), args); + continue; + } + let Some(op) = self.binop(allow_union) else { break }; + let bp = op.precedence(); + if bp < min_bp { + break; + } + self.next(); + let rhs = self.expr(bp + 1, allow_union)?; + lhs = combine(op, lhs, rhs); + } + self.depth -= 1; + Ok(lhs) + } + + fn args(&mut self) -> Result, ParseError> { + let mut args = Vec::new(); + if *self.peek() == Tok::RParen { + self.next(); + return Ok(args); + } + loop { + let a = match self.peek() { + Tok::Comma | Tok::RParen => Expr::Missing, + _ => self.expr(0, false)?, + }; + args.push(a); + match self.next() { + Tok::Comma => continue, + Tok::RParen => break, + t => return Err(self.err(format!("expected `,` or `)`, found {}", describe(&t)))), + } + } + if args.len() > 255 { + return Err(self.err("too many arguments")); + } + Ok(args) + } + + fn prefix(&mut self, allow_union: bool) -> Result { + match self.next() { + Tok::Number(n) => Ok(Expr::Number(n)), + Tok::Text(s) => Ok(Expr::Text(Arc::from(s))), + Tok::Bool(b) => Ok(Expr::Bool(b)), + Tok::Error(e) => Ok(Expr::Error(e)), + Tok::Op("-") => Ok(Expr::Unary(UnOp::Neg, Box::new(self.expr(60, allow_union)?))), + Tok::Op("+") => Ok(Expr::Unary(UnOp::Plus, Box::new(self.expr(60, allow_union)?))), + Tok::Op("@") => Ok(Expr::Unary(UnOp::At, Box::new(self.expr(95, allow_union)?))), + Tok::LParen => { + let e = self.expr(0, true)?; + self.expect(Tok::RParen)?; + Ok(Expr::Paren(Box::new(e))) + } + Tok::LBrace => self.array(), + Tok::Func(name) => { + let name = normalize_function_name(&name); + let args = self.args()?; + Ok(Expr::Call(name, args)) + } + Tok::Sheet(a, b) => { + let sheet = match b { + Some(b) => SheetSel::Span(a, b), + None => SheetSel::Named(a), + }; + match self.next() { + Tok::Word(w) => self.reference_from_word(sheet, &w), + Tok::Error(CellError::Ref) => Ok(Expr::Error(CellError::Ref)), + t => Err(self.err(format!("expected a reference after the sheet name, found {}", describe(&t)))), + } + } + Tok::Word(w) => { + if let Tok::Struct(_) = self.peek() + && word_is_cell(&w).is_none() + { + let Tok::Struct(body) = self.next() else { return Err(self.err("internal")) }; + return Ok(Expr::Struct(parse_struct(&w, &body))); + } + self.reference_from_word(SheetSel::Current, &w) + } + Tok::Struct(body) => Ok(Expr::Struct(parse_struct("", &body))), + t => Err(self.err(format!("unexpected {}", describe(&t)))), + } + } + + /// A word after an optional sheet: a cell, a column/row range (`A:C`, `3:5`) or a name. + fn reference_from_word(&mut self, sheet: SheetSel, w: &str) -> Result { + if let Some(a) = word_is_cell(w) { + // Cell, possibly `A1:B2` (merged here so the sheet applies to both ends). + if *self.peek() == Tok::Colon + && let Tok::Word(w2) = self.peek_at(1) + && let Some(b) = word_is_cell(w2) + { + self.next(); + self.next(); + return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) })); + } + return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) })); + } + let second = match self.peek_at(1) { + Tok::Word(w2) => Some(w2.clone()), + Tok::Number(n) if n.fract() == 0.0 && *n >= 1.0 => Some(format!("{n}")), + _ => None, + }; + if *self.peek() == Tok::Colon + && let Some(w2) = second + { + if let (Some((c0, a0)), Some((c1, a1))) = (word_is_cols(w), word_is_cols(&w2)) { + self.next(); + self.next(); + return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cols(c0, a0, c1, a1) })); + } + if let (Some((r0, a0)), Some((r1, a1))) = (word_is_row(w), word_is_row(&w2)) { + self.next(); + self.next(); + return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Rows(r0, a0, r1, a1) })); + } + } + if sheet != SheetSel::Current { + // Sheet-scoped name `Sheet1!MyName`. + if let SheetSel::Named(s) = sheet { + return Ok(Expr::Name(format!("{s}!{w}"))); + } + return Err(self.err("invalid 3-D reference")); + } + if w.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return Err(self.err(format!("unexpected `{w}`"))); + } + Ok(Expr::Name(w.to_string())) + } + + fn array(&mut self) -> Result { + let mut rows = vec![Vec::new()]; + loop { + let el = match self.next() { + Tok::Number(n) => Expr::Number(n), + Tok::Op("-") => match self.next() { + Tok::Number(n) => Expr::Number(-n), + t => return Err(self.err(format!("expected a number in array, found {}", describe(&t)))), + }, + Tok::Op("+") => match self.next() { + Tok::Number(n) => Expr::Number(n), + t => return Err(self.err(format!("expected a number in array, found {}", describe(&t)))), + }, + Tok::Text(s) => Expr::Text(Arc::from(s)), + Tok::Bool(b) => Expr::Bool(b), + Tok::Error(e) => Expr::Error(e), + t => return Err(self.err(format!("array constants may only contain constants, found {}", describe(&t)))), + }; + if let Some(r) = rows.last_mut() { + r.push(el); + } + match self.next() { + Tok::Comma => {} + Tok::Semicolon => rows.push(Vec::new()), + Tok::RBrace => break, + t => return Err(self.err(format!("expected `,`, `;` or `}}`, found {}", describe(&t)))), + } + } + let w = rows.first().map_or(0, Vec::len); + if rows.iter().any(|r| r.len() != w) { + return Err(self.err("array rows must have the same length")); + } + Ok(Expr::Array(rows)) + } +} + +/// Folds `A1:B2`-like range operations of two plain refs into a single reference. +fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr { + if op == BinOp::Range + && let (Expr::Ref(a), Expr::Ref(b)) = (&lhs, &rhs) + && b.sheet == SheetSel::Current + && let (RefKind::Cell(x), RefKind::Cell(y)) = (&a.kind, &b.kind) + { + return Expr::Ref(Reference { sheet: a.sheet.clone(), kind: RefKind::Range(*x, *y) }); + } + Expr::Binary(op, Box::new(lhs), Box::new(rhs)) +} + +/// Parses the inside of `Table[...]`. +pub fn parse_struct(table: &str, body: &str) -> StructRef { + let mut specifiers = Vec::new(); + let mut cols: Vec = Vec::new(); + let body = body.trim(); + let unescape = |s: &str| { + let mut out = String::new(); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c == '\'' { + if let Some(n) = it.next() { + out.push(n); + } + } else { + out.push(c); + } + } + out + }; + let item = |s: &str| -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "#all" => Some(StructItem::All), + "#data" => Some(StructItem::Data), + "#headers" => Some(StructItem::Headers), + "#totals" => Some(StructItem::Totals), + "#this row" => Some(StructItem::ThisRow), + _ => None, + } + }; + if !body.starts_with('[') { + // Simple: [Col], [@Col], [#Headers], [@] + if let Some(rest) = body.strip_prefix('@') { + specifiers.push(StructItem::ThisRow); + let rest = rest.trim().trim_start_matches('[').trim_end_matches(']'); + if !rest.is_empty() { + cols.push(unescape(rest)); + } + } else if let Some(i) = item(body) { + specifiers.push(i); + } else if !body.is_empty() { + cols.push(unescape(body)); + } + } else { + // [[#Headers],[Col1]:[Col3]] or [@[Col 1]] + let mut parts = Vec::new(); + let mut depth = 0; + let mut cur = String::new(); + let mut esc = false; + for c in body.chars() { + if esc { + cur.push(c); + esc = false; + continue; + } + match c { + '\'' => { + cur.push(c); + esc = true; + } + '[' => { + depth += 1; + if depth > 1 { + cur.push(c); + } + } + ']' => { + depth -= 1; + if depth > 0 { + cur.push(c); + } else { + parts.push(std::mem::take(&mut cur)); + } + } + ',' if depth == 0 => {} + ':' if depth == 0 => parts.push(":".into()), + _ if depth > 0 => cur.push(c), + _ => {} + } + } + let mut range_next = false; + for p in parts { + if p == ":" { + range_next = true; + continue; + } + if let Some(i) = item(&p) { + specifiers.push(i); + } else if range_next { + cols.push(unescape(&p)); + range_next = false; + } else { + cols.push(unescape(&p)); + } + } + if body.starts_with("@") { + specifiers.push(StructItem::ThisRow); + } + } + let mut it = cols.into_iter(); + let col_start = it.next(); + let col_end = it.next(); + StructRef { table: table.to_string(), specifiers, col_start, col_end } +} diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs new file mode 100644 index 00000000..2ffafa62 --- /dev/null +++ b/crates/formula/src/printer.rs @@ -0,0 +1,224 @@ +//! [`Expr`] → formula text (without `=`), A1 or R1C1. + +use std::fmt::Write; + +use sheetcraft_core::{CellRef, col_to_letters, number_to_text}; + +use crate::ast::*; + +/// Whether a sheet name needs quotes in a reference. +pub fn needs_quotes(name: &str) -> bool { + if name.is_empty() { + return true; + } + let first = name.chars().next().unwrap_or('_'); + if first.is_ascii_digit() || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { + return true; + } + // Looks like a cell ref (`A1`, `R1C1`) or a boolean. + sheetcraft_core::CellRef::parse(name).is_some() || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") +} + +pub fn quote_sheet(name: &str) -> String { + if needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } +} + +fn sheet_prefix(s: &SheetSel) -> String { + match s { + SheetSel::Current => String::new(), + SheetSel::Named(n) => format!("{}!", quote_sheet(n)), + SheetSel::Span(a, b) => { + let joined = format!("{a}:{b}"); + if needs_quotes(a) || needs_quotes(b) { format!("'{}'!", joined.replace('\'', "''")) } else { format!("{joined}!") } + } + } +} + +fn anchor_a1(a: &Anchor) -> String { + format!("{}{}{}{}", if a.col_abs { "$" } else { "" }, col_to_letters(a.col), if a.row_abs { "$" } else { "" }, a.row as u64 + 1) +} + +pub fn reference_a1(r: &Reference) -> String { + let body = match &r.kind { + RefKind::Cell(a) => anchor_a1(a), + RefKind::Range(a, b) => format!("{}:{}", anchor_a1(a), anchor_a1(b)), + RefKind::Rows(r0, a0, r1, a1) => format!("{}{}:{}{}", if *a0 { "$" } else { "" }, r0 + 1, if *a1 { "$" } else { "" }, r1 + 1), + RefKind::Cols(c0, a0, c1, a1) => { + format!("{}{}:{}{}", if *a0 { "$" } else { "" }, col_to_letters(*c0), if *a1 { "$" } else { "" }, col_to_letters(*c1)) + } + }; + format!("{}{}", sheet_prefix(&r.sheet), body) +} + +fn r1c1_part(letter: char, v: u32, abs: bool, base: u32) -> String { + if abs { + format!("{letter}{}", v + 1) + } else { + let d = v as i64 - base as i64; + if d == 0 { letter.to_string() } else { format!("{letter}[{d}]") } + } +} + +fn anchor_r1c1(a: &Anchor, at: CellRef) -> String { + format!("{}{}", r1c1_part('R', a.row, a.row_abs, at.row), r1c1_part('C', a.col, a.col_abs, at.col)) +} + +pub fn reference_r1c1(r: &Reference, at: CellRef) -> String { + let body = match &r.kind { + RefKind::Cell(a) => anchor_r1c1(a, at), + RefKind::Range(a, b) => format!("{}:{}", anchor_r1c1(a, at), anchor_r1c1(b, at)), + RefKind::Rows(r0, a0, r1, a1) => { + let (x, y) = (r1c1_part('R', *r0, *a0, at.row), r1c1_part('R', *r1, *a1, at.row)); + if x == y { x } else { format!("{x}:{y}") } + } + RefKind::Cols(c0, a0, c1, a1) => { + let (x, y) = (r1c1_part('C', *c0, *a0, at.col), r1c1_part('C', *c1, *a1, at.col)); + if x == y { x } else { format!("{x}:{y}") } + } + }; + format!("{}{}", sheet_prefix(&r.sheet), body) +} + +pub fn struct_ref(s: &StructRef) -> String { + let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#"); + let item = |i: &StructItem| match i { + StructItem::All => "#All", + StructItem::Data => "#Data", + StructItem::Headers => "#Headers", + StructItem::Totals => "#Totals", + StructItem::ThisRow => "#This Row", + }; + let this_row = s.specifiers == [StructItem::ThisRow]; + let mut out = s.table.clone(); + let simple_col = |c: &str| !c.chars().any(|ch| matches!(ch, ' ' | '[' | ']' | '#' | '\'' | ',' | ':')); + match (&s.col_start, &s.col_end) { + (None, None) if s.specifiers.is_empty() => out.push_str("[]"), + (None, None) if this_row => out.push_str("[@]"), + (None, None) if s.specifiers.len() == 1 => { + let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or("")); + } + (Some(c), None) if s.specifiers.is_empty() => { + let _ = write!(out, "[{}]", esc(c)); + } + (Some(c), None) if this_row => { + if simple_col(c) { + let _ = write!(out, "[@{}]", esc(c)); + } else { + let _ = write!(out, "[@[{}]]", esc(c)); + } + } + _ => { + let mut parts: Vec = s.specifiers.iter().map(|i| format!("[{}]", item(i))).collect(); + match (&s.col_start, &s.col_end) { + (Some(a), Some(b)) => parts.push(format!("[{}]:[{}]", esc(a), esc(b))), + (Some(a), None) => parts.push(format!("[{}]", esc(a))), + _ => {} + } + let _ = write!(out, "[{}]", parts.join(",")); + } + } + out +} + +fn number(n: f64) -> String { + number_to_text(n) +} + +/// Prints an expression in A1 notation. +pub fn print(e: &Expr) -> String { + let mut s = String::new(); + write_expr(&mut s, e, None); + s +} + +/// Prints an expression in R1C1 notation relative to `at`. +pub fn print_r1c1(e: &Expr, at: CellRef) -> String { + let mut s = String::new(); + write_expr(&mut s, e, Some(at)); + s +} + +fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { + match e { + Expr::Number(n) => s.push_str(&number(*n)), + Expr::Text(t) => { + s.push('"'); + s.push_str(&t.replace('"', "\"\"")); + s.push('"'); + } + Expr::Bool(b) => s.push_str(if *b { "TRUE" } else { "FALSE" }), + Expr::Error(err) => s.push_str(err.as_str()), + Expr::Array(rows) => { + s.push('{'); + for (ri, row) in rows.iter().enumerate() { + if ri > 0 { + s.push(';'); + } + for (ci, el) in row.iter().enumerate() { + if ci > 0 { + s.push(','); + } + write_expr(s, el, r1c1); + } + } + s.push('}'); + } + Expr::Ref(r) => match r1c1 { + Some(at) => s.push_str(&reference_r1c1(r, at)), + None => s.push_str(&reference_a1(r)), + }, + Expr::Name(n) => s.push_str(n), + Expr::Struct(st) => s.push_str(&struct_ref(st)), + Expr::Unary(op, x) => match op { + UnOp::Neg => { + s.push('-'); + write_expr(s, x, r1c1); + } + UnOp::Plus => { + s.push('+'); + write_expr(s, x, r1c1); + } + UnOp::At => { + s.push('@'); + write_expr(s, x, r1c1); + } + UnOp::Percent => { + write_expr(s, x, r1c1); + s.push('%'); + } + }, + Expr::Binary(op, a, b) => { + write_expr(s, a, r1c1); + s.push_str(op.symbol()); + write_expr(s, b, r1c1); + } + Expr::Call(name, args) => { + s.push_str(name); + s.push('('); + for (i, a) in args.iter().enumerate() { + if i > 0 { + s.push(','); + } + write_expr(s, a, r1c1); + } + s.push(')'); + } + Expr::Invoke(c, args) => { + write_expr(s, c, r1c1); + s.push('('); + for (i, a) in args.iter().enumerate() { + if i > 0 { + s.push(','); + } + write_expr(s, a, r1c1); + } + s.push(')'); + } + Expr::Missing => {} + Expr::Paren(x) => { + s.push('('); + write_expr(s, x, r1c1); + s.push(')'); + } + } +} diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs new file mode 100644 index 00000000..49e58000 --- /dev/null +++ b/crates/formula/src/tests.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::adjust::{Axis, Edit, adjust, rename_sheet, shift_relative}; + +fn rt(src: &str) -> String { + print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}"))) +} + +#[test] +fn roundtrips() { + for f in [ + "1+2*3", + "SUM(A1:B2)", + "SUM($A$1:B$2,C3)", + "IF(A1>0,\"yes\",\"no\")", + "-2^2", + "A1%", + "Sheet2!A1+'My Sheet'!$B$3", + "SUM(Sheet1:Sheet3!A1)", + "SUM(A:A)", + "SUM(3:5)", + "{1,2;3,4}", + "{-1,\"a\",TRUE,#N/A}", + "VLOOKUP(A1,Table1[#All],2,FALSE)", + "Table1[Sales]", + "Table1[@Sales]", + "Table1[[#Headers],[A]:[C]]", + "\"a\"\"b\"&C1", + "SUM((A1:A3,C1:C3))", + "A1:C3 B2:B5", + "LAMBDA(x,x+1)(2)", + "F(1,,3)", + "@A1:A10", + "A1<>B1", + "A1<=B1", + "#REF!+1", + "1E+20", + "MyName*2", + "Sheet1!MyName", + "SUM(A1:B2) ", + ] { + let p = rt(f); + assert_eq!(p, f.trim(), "round trip of {f}"); + } +} + +#[test] +fn normalises() { + assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)"); + assert_eq!(rt("= 1 + 2"), "1+2"); + assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); + assert_eq!(rt("true"), "TRUE"); + assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); +} + +#[test] +fn precedence() { + let e = parse("1+2*3").unwrap(); + assert!(matches!(e, Expr::Binary(BinOp::Add, _, _))); + let e = parse("-2^2").unwrap(); + assert!(matches!(e, Expr::Binary(BinOp::Pow, _, _))); + let e = parse("1&2=\"12\"").unwrap(); + assert!(matches!(e, Expr::Binary(BinOp::Eq, _, _))); + let e = parse("2^3^2").unwrap(); // left associative: (2^3)^2 + if let Expr::Binary(BinOp::Pow, l, _) = e { + assert!(matches!(*l, Expr::Binary(BinOp::Pow, _, _))); + } else { + panic!() + } +} + +#[test] +fn errors() { + for bad in ["1+", "SUM(1,2", "(1", "{1,2;3}", "\"abc", "A1:", "1 2", "{A1}", ")", "Sheet1!", "#FOO!"] { + assert!(parse(bad).is_err(), "{bad} should fail"); + } +} + +#[test] +fn no_panic_on_garbage() { + let samples = ["(((((", "[[[", "'''", "!!!", "$$$", "=@@@", "A1:B2:C3:D4", "{,}", "{;}", "F(,,,,)", ":::", "1e", "1e+", ".", "..", "$A$", "XFD1048577"]; + for s in samples { + let _ = parse(s); + } + let deep = "(".repeat(5000) + "1" + &")".repeat(5000); + assert!(parse(&deep).is_err()); +} + +#[test] +fn references() { + let e = parse("SUM(A1:B2)+Sheet2!C3*D4").unwrap(); + let refs: Vec = e.references().iter().map(|r| printer::reference_a1(r)).collect(); + assert_eq!(refs, ["A1:B2", "Sheet2!C3", "D4"]); +} + +#[test] +fn copy_shift() { + let e = parse("A1+$A1+A$1+$A$1+SUM(B2:C3)").unwrap(); + assert_eq!(print(&shift_relative(e, 2, 1)), "B3+$A3+B$1+$A$1+SUM(C4:D5)"); + let e = parse("A1").unwrap(); + assert_eq!(print(&shift_relative(e, -1, 0)), "#REF!"); + let e = parse("SUM(A:A)").unwrap(); + assert_eq!(print(&shift_relative(e, 5, 1)), "SUM(B:B)"); +} + +#[test] +fn insert_delete() { + let ins = Edit::Insert { axis: Axis::Rows, at: 2, count: 2 }; + let e = parse("A1+A3+SUM(A2:A5)+Other!A3").unwrap(); + assert_eq!(print(&adjust(e, "Sheet1", "Sheet1", &ins)), "A1+A5+SUM(A2:A7)+Other!A3"); + let del = Edit::Delete { axis: Axis::Rows, at: 2, count: 2 }; // rows 3-4 + let e = parse("A1+A3+SUM(A2:A5)+A6").unwrap(); + assert_eq!(print(&adjust(e, "Sheet1", "Sheet1", &del)), "A1+#REF!+SUM(A2:A3)+A4"); + let del = Edit::Delete { axis: Axis::Cols, at: 0, count: 1 }; + let e = parse("SUM(A1:C1)+B2").unwrap(); + assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(A1:B1)+A2"); + let e = parse("Sheet1!B2").unwrap(); + assert_eq!(print(&adjust(e, "Other", "Sheet1", &del)), "Sheet1!A2"); + let e = parse("SUM(A:A)").unwrap(); + assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(#REF!)"); +} + +#[test] +fn moves_and_renames() { + let mv = Edit::Move { from: sheetcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 }; + let e = parse("A1+B2+C3+SUM(A1:B2)").unwrap(); + assert_eq!(print(&adjust(e, "S", "S", &mv)), "D10+E11+C3+SUM(D10:E11)"); + let e = parse("Old!A1+'Old'!B2").unwrap(); + assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2"); +} + +#[test] +fn r1c1() { + let e = parse("A1+$B$2+SUM(C:C)").unwrap(); + assert_eq!(print_r1c1(&e, sheetcraft_core::CellRef::new(1, 1)), "R[-1]C[-1]+R2C2+SUM(C[1])"); +} + +#[test] +fn spans() { + let toks = lexer::tokenize("SUM(A1, B2)").unwrap(); + let a1 = toks.iter().find(|t| t.tok == lexer::Tok::Word("A1".into())).unwrap(); + assert_eq!((a1.start, a1.end), (4, 6)); +} diff --git a/crates/functions/Cargo.toml b/crates/functions/Cargo.toml new file mode 100644 index 00000000..0f8bbcf3 --- /dev/null +++ b/crates/functions/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "sheetcraft-functions" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft worksheet function library: math, statistics, text, dates, lookup, financial, engineering" + +[lints] +workspace = true + +[dependencies] +sheetcraft-core = { workspace = true } +regex = { workspace = true } diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs new file mode 100644 index 00000000..f3758e7a --- /dev/null +++ b/crates/functions/src/criteria.rs @@ -0,0 +1,314 @@ +//! Excel criteria (COUNTIF, SUMIFS, DCOUNT…) and wildcard matching. + +use std::cmp::Ordering; + +use sheetcraft_core::parse::parse_number_text; +use sheetcraft_core::{CellError, Value, compare_text}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Op { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +#[derive(Clone, Debug)] +enum Operand { + Number(f64), + Bool(bool), + Error(CellError), + /// Text (lower-cased chars kept for wildcard matching). + Text(String), + /// Nothing after the operator (`"="`, `"<>"`) or an empty criterion. + Blank, +} + +/// A parsed criterion. +#[derive(Clone, Debug)] +pub(crate) struct Criterion { + op: Op, + operand: Operand, + /// The criterion was a bare `""` (or empty cell text): matches blanks and empty strings. + bare_empty: bool, +} + +impl Criterion { + /// Parses a criterion value the way COUNTIF does. + pub(crate) fn parse(v: &Value) -> Criterion { + match v { + Value::Number(n) => Criterion { op: Op::Eq, operand: Operand::Number(*n), bare_empty: false }, + Value::Bool(b) => Criterion { op: Op::Eq, operand: Operand::Bool(*b), bare_empty: false }, + Value::Error(e) => Criterion { op: Op::Eq, operand: Operand::Error(*e), bare_empty: false }, + Value::Empty => Criterion { op: Op::Eq, operand: Operand::Number(0.0), bare_empty: false }, + Value::Array(a) => Criterion::parse(a.data.first().unwrap_or(&Value::Empty)), + Value::Text(t) => Criterion::parse_text(t), + } + } + + fn parse_text(t: &str) -> Criterion { + let (op, rest) = if let Some(r) = t.strip_prefix(">=") { + (Op::Ge, r) + } else if let Some(r) = t.strip_prefix("<=") { + (Op::Le, r) + } else if let Some(r) = t.strip_prefix("<>") { + (Op::Ne, r) + } else if let Some(r) = t.strip_prefix('=') { + (Op::Eq, r) + } else if let Some(r) = t.strip_prefix('>') { + (Op::Gt, r) + } else if let Some(r) = t.strip_prefix('<') { + (Op::Lt, r) + } else { + if t.is_empty() { + return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true }; + } + (Op::Eq, t) + }; + let operand = if rest.is_empty() { + Operand::Blank + } else if let Some(n) = parse_number_text(rest) { + Operand::Number(n) + } else if rest.eq_ignore_ascii_case("TRUE") { + Operand::Bool(true) + } else if rest.eq_ignore_ascii_case("FALSE") { + Operand::Bool(false) + } else if let Some(e) = CellError::parse(rest) { + Operand::Error(e) + } else { + Operand::Text(rest.to_string()) + }; + Criterion { op, operand, bare_empty: false } + } + + /// Whether a cell value satisfies the criterion. + pub(crate) fn matches(&self, v: &Value) -> bool { + let v = match v { + Value::Array(a) => a.data.first().cloned().unwrap_or(Value::Empty), + other => other.clone(), + }; + if self.bare_empty { + return matches!(&v, Value::Empty) || matches!(&v, Value::Text(t) if t.is_empty()); + } + match &self.operand { + Operand::Blank => { + let blank = matches!(v, Value::Empty); + match self.op { + Op::Eq => blank, + Op::Ne => !blank, + // "<" / ">" with nothing: Excel compares against "" for text cells. + _ => match &v { + Value::Text(t) => cmp_op(self.op, compare_text(t, "")), + _ => false, + }, + } + } + Operand::Number(n) => { + let cell = match &v { + Value::Number(x) => Some(*x), + // Numeric text matches only for equality (COUNTIF("1") counts text "1"). + Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t), + _ => None, + }; + match cell { + Some(x) => cmp_op(self.op, x.partial_cmp(n).unwrap_or(Ordering::Equal)), + None => self.op == Op::Ne, + } + } + Operand::Bool(b) => match &v { + Value::Bool(x) => cmp_op(self.op, x.cmp(b)), + _ => self.op == Op::Ne, + }, + Operand::Error(e) => match &v { + Value::Error(x) => cmp_op(self.op, x.code().cmp(&e.code())), + _ => self.op == Op::Ne, + }, + Operand::Text(pat) => match self.op { + Op::Eq | Op::Ne => { + let cell_text = match &v { + Value::Text(t) => Some(t.to_string()), + Value::Empty => Some(String::new()), + _ => None, + }; + let m = cell_text.is_some_and(|t| wildcard_match(pat, &t)); + if self.op == Op::Eq { m } else { !m } + } + _ => match &v { + Value::Text(t) => cmp_op(self.op, compare_text(t, pat)), + _ => false, + }, + }, + } + } +} + +fn cmp_op(op: Op, o: Ordering) -> bool { + match op { + Op::Eq => o == Ordering::Equal, + Op::Ne => o != Ordering::Equal, + Op::Lt => o == Ordering::Less, + Op::Le => o != Ordering::Greater, + Op::Gt => o == Ordering::Greater, + Op::Ge => o != Ordering::Less, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Tok { + Char(char), + Any, + Star, +} + +fn tokenize(pattern: &str) -> Vec { + let mut out = Vec::new(); + let mut it = pattern.chars(); + while let Some(c) = it.next() { + match c { + '~' => match it.next() { + Some(n) => out.extend(n.to_lowercase().map(Tok::Char)), + None => out.push(Tok::Char('~')), + }, + '*' => out.push(Tok::Star), + '?' => out.push(Tok::Any), + c => out.extend(c.to_lowercase().map(Tok::Char)), + } + } + out +} + +fn lower_chars(s: &str) -> Vec { + s.chars().flat_map(char::to_lowercase).collect() +} + +/// Whether the pattern contains an unescaped wildcard. +pub(crate) fn has_wildcards(pattern: &str) -> bool { + tokenize(pattern).iter().any(|t| !matches!(t, Tok::Char(_))) || pattern.contains('~') +} + +/// Case-insensitive whole-text match with `*`, `?` and `~` escapes. +pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool { + let p = tokenize(pattern); + let t = lower_chars(text); + match_from(&p, &t, true).is_some() +} + +/// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t` +/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish). +fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option { + let (mut pi, mut ti) = (0usize, 0usize); + let mut star: Option<(usize, usize)> = None; + loop { + if pi == p.len() && (!whole || ti == t.len()) { + return Some(ti); + } + let step = match (p.get(pi), t.get(ti)) { + (Some(Tok::Star), _) => { + star = Some((pi, ti)); + pi += 1; + continue; + } + (Some(Tok::Any), Some(_)) => true, + (Some(Tok::Char(c)), Some(x)) => c == x, + _ => false, + }; + if step { + pi += 1; + ti += 1; + continue; + } + match star { + Some((sp, st)) if st < t.len() => { + star = Some((sp, st + 1)); + pi = sp + 1; + ti = st + 1; + } + _ => return None, + } + } +} + +/// SEARCH-style find: the char index (0-based) of the first position at or after `start` where +/// the wildcard pattern matches, case-insensitively. +pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option { + let p = tokenize(pattern); + let t = lower_chars(text); + if p.is_empty() { + return if start <= t.len() { Some(start) } else { None }; + } + (start..t.len()).find(|&i| t.get(i..).is_some_and(|rest| match_from(&p, rest, false).is_some())) +} + +/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards +/// matches by pattern (only when `wild`), otherwise values compare case-insensitively. +pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool { + match (needle, hay) { + (Value::Text(p), Value::Text(t)) => { + if wild { + wildcard_match(p, t) + } else { + compare_text(p, t) == Ordering::Equal + } + } + (Value::Number(a), Value::Number(b)) => a == b, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Error(a), Value::Error(b)) => a == b, + (Value::Empty, Value::Empty) => true, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn m(c: &str, v: Value) -> bool { + Criterion::parse(&Value::from(c)).matches(&v) + } + + #[test] + fn criteria() { + assert!(m(">5", Value::Number(6.0))); + assert!(!m(">5", Value::Number(5.0))); + assert!(m(">=5", Value::Number(5.0))); + assert!(m("<>5", Value::from("x"))); + assert!(m("<>5", Value::Empty)); + assert!(!m(">5", Value::from("9"))); + assert!(m("5", Value::from("5"))); + assert!(m("a*", Value::from("Apple"))); + assert!(!m("a*", Value::from("banana"))); + assert!(m("?pple", Value::from("apple"))); + assert!(m("~*", Value::from("*"))); + assert!(!m("~*", Value::from("x"))); + assert!(m("", Value::Empty)); + assert!(m("", Value::from(""))); + assert!(!m("", Value::Number(0.0))); + assert!(m("=", Value::Empty)); + assert!(!m("=", Value::from("a"))); + assert!(m("<>", Value::from("a"))); + assert!(!m("<>", Value::Empty)); + assert!(m("TRUE", Value::Bool(true))); + assert!(m("a*", Value::from("bcd"))); + assert!(m(">1/1/2020", Value::Number(44000.0))); + } + + #[test] + fn wildcards() { + assert!(wildcard_match("*", "")); + assert!(wildcard_match("a*c", "abbbc")); + assert!(!wildcard_match("a*c", "abbbd")); + assert!(wildcard_match("*~?", "what?")); + assert_eq!(wildcard_find("b?d", "abcd abd", 0), Some(1)); + assert_eq!(wildcard_find("b?d", "abxxd bcd", 0), Some(6)); + assert_eq!(wildcard_find("c*", "abcd", 0), Some(2)); + assert_eq!(wildcard_find("z", "abcd", 0), None); + assert!(has_wildcards("a*")); + assert!(!has_wildcards("abc")); + } +} diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs new file mode 100644 index 00000000..35ae71f7 --- /dev/null +++ b/crates/functions/src/database.rs @@ -0,0 +1,374 @@ +//! Database functions (DSUM, DCOUNT, DGET…): aggregate one field of the records of a table +//! that satisfy a criteria table. +//! +//! The database and the criteria are arrays whose first row holds column labels. Each criteria +//! row is a set of conditions that must all hold (AND); a record matches when any criteria row +//! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that +//! begin with the text, as in Excel's advanced filter. + +use std::borrow::Cow; + +use sheetcraft_core::{Array, CellError, Value, compare_text}; + +use crate::criteria::Criterion; +use crate::util::{A, R, as_array, has, num_val}; +use crate::{Arg, Ctx, FnSpec}; + +/// Column index of a label in the header row. +fn find_column(db: &Array, label: &str) -> Option { + let label = label.trim(); + (0..db.cols).find(|&c| match db.get(0, c) { + Some(Value::Text(t)) => compare_text(t.trim(), label).is_eq(), + Some(Value::Number(n)) => sheetcraft_core::number_to_text(*n) == label, + _ => false, + }) +} + +/// Resolves the field argument: a label or a 1-based column number. `None` when omitted. +fn field_index(db: &Array, a: &[Arg]) -> R> { + if !has(a, 1) { + return Ok(None); + } + let v = a.get(1).map(|x| x.value.scalar()).unwrap_or(Value::Empty); + match v { + Value::Number(n) => { + let i = n.trunc(); + if i < 1.0 || i > db.cols as f64 { + return Err(CellError::Value); + } + Ok(Some(i as usize - 1)) + } + Value::Text(t) => find_column(db, &t).map(Some).ok_or(CellError::Value), + Value::Bool(_) => Err(CellError::Value), + Value::Error(e) => Err(e), + _ => Ok(None), + } +} + +/// One criteria cell as a matcher. Plain text becomes a "begins with" pattern. +fn cell_criterion(v: &Value) -> Option { + match v { + Value::Empty => None, + Value::Text(t) if t.is_empty() => None, + Value::Text(t) => { + let s: &str = t; + let has_op = s.starts_with(['=', '<', '>']); + let plain_text = !has_op + && sheetcraft_core::parse::parse_number_text(s).is_none() + && !s.eq_ignore_ascii_case("TRUE") + && !s.eq_ignore_ascii_case("FALSE") + && CellError::parse(s).is_none(); + if plain_text && !s.ends_with('*') { Some(Criterion::parse(&Value::from(format!("{s}*")))) } else { Some(Criterion::parse(v)) } + } + other => Some(Criterion::parse(other)), + } +} + +/// Rows (1-based within the database, excluding the header) of matching records. +fn matching_records(db: &Array, crit: &Array) -> Vec { + // Per criteria row: list of (database column or None when the label is unknown, criterion). + let mut rows: Vec, Criterion)>> = Vec::new(); + for r in 1..crit.rows { + let mut conds = Vec::new(); + for c in 0..crit.cols { + let Some(cell) = crit.get(r, c) else { continue }; + let Some(cr) = cell_criterion(cell) else { continue }; + let col = match crit.get(0, c) { + Some(Value::Text(t)) => find_column(db, t), + Some(Value::Number(n)) => find_column(db, &sheetcraft_core::number_to_text(*n)), + _ => None, + }; + conds.push((col, cr)); + } + rows.push(conds); + } + let all_match = rows.is_empty(); + (1..db.rows) + .filter(|&r| { + all_match + || rows.iter().any(|conds| { + conds.iter().all(|(col, cr)| match col { + Some(c) => cr.matches(db.get(r, *c).unwrap_or(&Value::Empty)), + None => false, + }) + }) + }) + .collect() +} + +struct Selection<'a> { + db: Cow<'a, Array>, + field: Option, + records: Vec, +} + +fn select(a: &[Arg]) -> R> { + let dbv = &a.first().ok_or(CellError::Value)?.value; + let critv = &a.get(2).ok_or(CellError::Value)?.value; + if let Value::Error(e) = dbv { + return Err(*e); + } + if let Value::Error(e) = critv { + return Err(*e); + } + let db = as_array(dbv); + let crit = as_array(critv); + let field = field_index(&db, a)?; + let records = matching_records(&db, &crit); + Ok(Selection { db, field, records }) +} + +/// Numbers of the field among matching records (field required). +fn field_numbers(a: &[Arg]) -> R> { + let s = select(a)?; + let f = s.field.ok_or(CellError::Value)?; + Ok(s.records.iter().filter_map(|&r| s.db.get(r, f).and_then(Value::as_f64)).collect()) +} + +fn dsum(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(field_numbers(a)?.iter().sum()) +} + +fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = field_numbers(a)?; + if v.is_empty() { + return Err(CellError::Div0); + } + num_val(v.iter().sum::() / v.len() as f64) +} + +fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = select(a)?; + let n = match s.field { + Some(f) => s.records.iter().filter(|&&r| matches!(s.db.get(r, f), Some(Value::Number(_)))).count(), + None => s.records.len(), + }; + num_val(n as f64) +} + +fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = select(a)?; + let n = match s.field { + Some(f) => s.records.iter().filter(|&&r| !matches!(s.db.get(r, f), None | Some(Value::Empty))).count(), + None => s.records.len(), + }; + num_val(n as f64) +} + +fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = select(a)?; + let f = s.field.ok_or(CellError::Value)?; + match s.records.as_slice() { + [] => Err(CellError::Value), + [r] => Ok(s.db.get(*r, f).cloned().unwrap_or(Value::Empty)), + _ => Err(CellError::Num), + } +} + +fn dmax(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = field_numbers(a)?; + num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) +} + +fn dmin(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = field_numbers(a)?; + num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) +} + +fn dproduct(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = field_numbers(a)?; + if v.is_empty() { + return num_val(0.0); + } + num_val(v.iter().product()) +} + +fn variance(v: &[f64], sample: bool) -> R { + let n = v.len() as f64; + let min = if sample { 2.0 } else { 1.0 }; + if n < min { + return Err(CellError::Div0); + } + let mean = v.iter().sum::() / n; + let ss: f64 = v.iter().map(|x| (x - mean) * (x - mean)).sum(); + Ok(ss / if sample { n - 1.0 } else { n }) +} + +fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a)?, true)?) +} + +fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a)?, false)?) +} + +fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a)?, true)?.sqrt()) +} + +fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a)?, false)?.sqrt()) +} + +pub(crate) fn specs() -> Vec { + vec![ + f!("DSUM", 3, 3, Database, A, "DSUM(database, field, criteria)", "Adds the numbers in a field of the records that meet the criteria.", dsum), + f!( + "DAVERAGE", + 3, + 3, + Database, + A, + "DAVERAGE(database, field, criteria)", + "Averages the numbers in a field of the records that meet the criteria.", + daverage + ), + f!( + "DCOUNT", + 3, + 3, + Database, + A, + "DCOUNT(database, [field], criteria)", + "Counts numeric cells in a field of the matching records (or the matching records).", + dcount + ), + f!( + "DCOUNTA", + 3, + 3, + Database, + A, + "DCOUNTA(database, [field], criteria)", + "Counts non-blank cells in a field of the matching records (or the matching records).", + dcounta + ), + f!( + "DGET", + 3, + 3, + Database, + A, + "DGET(database, field, criteria)", + "Returns the field value of the single record that meets the criteria.", + dget + ), + f!("DMAX", 3, 3, Database, A, "DMAX(database, field, criteria)", "Largest number in a field of the matching records.", dmax), + f!("DMIN", 3, 3, Database, A, "DMIN(database, field, criteria)", "Smallest number in a field of the matching records.", dmin), + f!( + "DPRODUCT", + 3, + 3, + Database, + A, + "DPRODUCT(database, field, criteria)", + "Multiplies the numbers in a field of the matching records.", + dproduct + ), + f!( + "DSTDEV", + 3, + 3, + Database, + A, + "DSTDEV(database, field, criteria)", + "Sample standard deviation of a field over the matching records.", + dstdev + ), + f!( + "DSTDEVP", + 3, + 3, + Database, + A, + "DSTDEVP(database, field, criteria)", + "Population standard deviation of a field over the matching records.", + dstdevp + ), + f!("DVAR", 3, 3, Database, A, "DVAR(database, field, criteria)", "Sample variance of a field over the matching records.", dvar), + f!("DVARP", 3, 3, Database, A, "DVARP(database, field, criteria)", "Population variance of a field over the matching records.", dvarp), + ] +} + +#[cfg(test)] +mod tests { + use crate::util::testutil::*; + use sheetcraft_core::{CellError, Value}; + + /// The classic orchard table. + fn db() -> Value { + arr(vec![ + vec![tv("Tree"), tv("Height"), tv("Age"), tv("Yield"), tv("Profit")], + vec![tv("Apple"), nv(18.0), nv(20.0), nv(14.0), nv(105.0)], + vec![tv("Pear"), nv(12.0), nv(12.0), nv(10.0), nv(96.0)], + vec![tv("Cherry"), nv(13.0), nv(14.0), nv(9.0), nv(105.0)], + vec![tv("Apple"), nv(14.0), nv(15.0), nv(10.0), nv(75.0)], + vec![tv("Pear"), nv(9.0), nv(8.0), nv(8.0), nv(76.8)], + vec![tv("Apple"), nv(8.0), nv(9.0), nv(6.0), nv(45.0)], + ]) + } + + /// Apple with height > 10, OR any pear. + fn crit() -> Value { + arr(vec![ + vec![tv("Tree"), tv("Height"), tv("Age"), tv("Yield"), tv("Profit"), tv("Height")], + vec![tv("=Apple"), tv(">10"), Value::Empty, Value::Empty, Value::Empty, tv("<16")], + vec![tv("=Pear"), Value::Empty, Value::Empty, Value::Empty, Value::Empty, Value::Empty], + ]) + } + + fn d(f: &str, field: crate::Arg) -> Value { + ev(f, vec![rf(db()), field, rf(crit())]) + } + + #[test] + fn orchard() { + close(d("DCOUNT", t("Age")), 3.0); + close(d("DCOUNTA", t("Profit")), 3.0); + close(d("DMAX", t("Profit")), 96.0); + close(d("DMIN", t("Profit")), 75.0); + close(d("DSUM", t("Profit")), 247.8); + close(d("DSUM", n(5.0)), 247.8); + close(d("DPRODUCT", t("Yield")), 800.0); + close(d("DAVERAGE", t("Yield")), 28.0 / 3.0); + close_tol(d("DSTDEV", t("Yield")), 1.154700538, 1e-9); + close_tol(d("DSTDEVP", t("Yield")), 0.942809042, 1e-9); + close_tol(d("DVAR", t("Yield")), 1.333333333, 1e-9); + close_tol(d("DVARP", t("Yield")), 0.888888889, 1e-9); + close(d("DCOUNT", empty()), 3.0); + is_err(d("DSUM", t("Nope")), CellError::Value); + is_err(d("DSUM", n(9.0)), CellError::Value); + } + + #[test] + fn simple_criteria() { + let c = |cells: Vec| arr(vec![vec![tv("Tree"), tv("Height")], cells]); + close(ev("DSUM", vec![rf(db()), t("Profit"), rf(c(vec![tv("Apple"), Value::Empty]))]), 225.0); + // Plain text matches values that begin with it. + close(ev("DCOUNT", vec![rf(db()), t("Age"), rf(c(vec![tv("P"), Value::Empty]))]), 2.0); + close(ev("DCOUNT", vec![rf(db()), t("Age"), rf(c(vec![tv("=P"), Value::Empty]))]), 0.0); + close(ev("DSUM", vec![rf(db()), t("Yield"), rf(c(vec![Value::Empty, tv(">=13")]))]), 33.0); + close(ev("DSUM", vec![rf(db()), t("Yield"), rf(c(vec![Value::Empty, nv(18.0)]))]), 14.0); + close(ev("DSUM", vec![rf(db()), t("Yield"), rf(c(vec![Value::Empty, Value::Empty]))]), 57.0); + close(ev("DSUM", vec![rf(db()), t("Yield"), rf(c(vec![tv("*e*"), tv("<10")]))]), 14.0); + // Header only: every record matches. + close(ev("DCOUNTA", vec![rf(db()), t("tree"), rf(arr(vec![vec![tv("Tree")]]))]), 6.0); + // Unknown criteria label never matches. + close(ev("DCOUNT", vec![rf(db()), t("Age"), rf(arr(vec![vec![tv("Color")], vec![tv("red")]]))]), 0.0); + } + + #[test] + fn dget_and_empty() { + let c = |tree: &str| arr(vec![vec![tv("Tree")], vec![tv(tree)]]); + close(ev("DGET", vec![rf(db()), t("Yield"), rf(c("=Cherry"))]), 9.0); + is_err(ev("DGET", vec![rf(db()), t("Yield"), rf(c("=Apple"))]), CellError::Num); + is_err(ev("DGET", vec![rf(db()), t("Yield"), rf(c("=Plum"))]), CellError::Value); + is_err(ev("DAVERAGE", vec![rf(db()), t("Yield"), rf(c("=Plum"))]), CellError::Div0); + close(ev("DMAX", vec![rf(db()), t("Yield"), rf(c("=Plum"))]), 0.0); + close(ev("DPRODUCT", vec![rf(db()), t("Yield"), rf(c("=Plum"))]), 0.0); + is_err(ev("DSTDEV", vec![rf(db()), t("Yield"), rf(c("=Cherry"))]), CellError::Div0); + close(ev("DSTDEVP", vec![rf(db()), t("Yield"), rf(c("=Cherry"))]), 0.0); + is_err(ev("DSUM", vec![e(CellError::Ref), t("Yield"), rf(c("=Cherry"))]), CellError::Ref); + close(ev("DCOUNTA", vec![rf(db()), empty(), rf(c("=Pear"))]), 2.0); + } +} diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs new file mode 100644 index 00000000..11ef89c5 --- /dev/null +++ b/crates/functions/src/date.rs @@ -0,0 +1,759 @@ +//! Date and time functions. + +use sheetcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd}; +use sheetcraft_core::parse::parse_input; +use sheetcraft_core::{CellError, DateSystem, Value}; + +use crate::util::{R, S, arg, as_array, has, num, num_val, opt_bool, opt_num, scalar, text, to_int}; +use crate::{Arg, Ctx, FnSpec}; + +const MAX_SERIAL: f64 = 2_958_465.0; +/// NETWORKDAYS / WORKDAY: start, end/days scalar; holidays take arrays. +const ND: &[bool] = &[true, true, false]; +const NDI: &[bool] = &[true, true, true, false]; + +/// A date serial argument (truncated to whole days when `whole`). Out of range → `#NUM!`. +fn serial_arg(a: &[Arg], i: usize) -> R { + let n = num(a, i)?; + if !(0.0..MAX_SERIAL + 1.0).contains(&n) { + return Err(CellError::Num); + } + Ok(n) +} + +fn day_arg(a: &[Arg], i: usize) -> R { + Ok(serial_arg(a, i)?.floor() as i64) +} + +fn dt(sys: DateSystem, serial: f64) -> R { + datetime_from_serial(sys, serial).ok_or(CellError::Num) +} + +fn ymd_serial(sys: DateSystem, y: i64, m: i64, d: i64) -> R { + serial_from_ymd(sys, y, m, d).map(|s| s as i64).ok_or(CellError::Num) +} + +/// Weekday of a serial, 0 = Sunday. +fn weekday0(sys: DateSystem, serial: i64) -> usize { + match sys { + DateSystem::D1900 => (serial + 6).rem_euclid(7) as usize, + DateSystem::D1904 => (serial + 5).rem_euclid(7) as usize, + } +} + +fn date(a: &[Arg], c: &mut dyn Ctx) -> R { + let y = to_int(num(a, 0)?)?; + let m = to_int(num(a, 1)?)?; + let d = to_int(num(a, 2)?)?; + if !(0..10000).contains(&y) || m.abs() > 1_000_000 || d.abs() > 100_000_000 { + return Err(CellError::Num); + } + num_val(ymd_serial(c.date_system(), y, m, d)? as f64) +} + +fn time(a: &[Arg], _c: &mut dyn Ctx) -> R { + let h = num(a, 0)?.trunc(); + let m = num(a, 1)?.trunc(); + let s = num(a, 2)?.trunc(); + if h > 32767.0 || m > 32767.0 || s > 32767.0 { + return Err(CellError::Num); + } + let total = h * 3600.0 + m * 60.0 + s; + if total < 0.0 { + return Err(CellError::Num); + } + num_val(total.rem_euclid(86400.0) / 86400.0) +} + +fn part(a: &[Arg], c: &mut dyn Ctx, f: fn(&DateTime) -> f64) -> R { + let s = serial_arg(a, 0)?; + num_val(f(&dt(c.date_system(), s.floor())?)) +} + +fn year(a: &[Arg], c: &mut dyn Ctx) -> R { + part(a, c, |d| d.year as f64) +} +fn month(a: &[Arg], c: &mut dyn Ctx) -> R { + part(a, c, |d| d.month as f64) +} +fn day(a: &[Arg], c: &mut dyn Ctx) -> R { + part(a, c, |d| d.day as f64) +} + +/// Seconds into the day, rounded to the nearest second. +fn day_seconds(a: &[Arg]) -> R { + let s = serial_arg(a, 0)?; + let secs = ((s - s.floor()) * 86400.0).round() as i64; + Ok(if secs >= 86400 { 0 } else { secs }) +} + +fn hour(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val((day_seconds(a)? / 3600) as f64) +} +fn minute(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val((day_seconds(a)? / 60 % 60) as f64) +} +fn second(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val((day_seconds(a)? % 60) as f64) +} + +fn now(_a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(c.now_serial()) +} +fn today(_a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(c.now_serial().floor()) +} + +fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = day_arg(a, 0)?; + let t = to_int(opt_num(a, 1, 1.0)?)?; + let wd = weekday0(c.date_system(), s) as i64; + let r = match t { + 1 | 17 => wd + 1, + 2 | 11 => (wd + 6) % 7 + 1, + 3 => (wd + 6) % 7, + 12..=16 => (wd + 7 - (t - 10)) % 7 + 1, + _ => return Err(CellError::Num), + }; + num_val(r as f64) +} + +fn isoweek(sys: DateSystem, s: i64) -> R { + let iso_wd = (weekday0(sys, s) + 6) % 7 + 1; // Monday = 1 + let thursday = s - iso_wd as i64 + 4; + let y = if thursday < 1 { 1900 } else { dt(sys, thursday as f64)?.year as i64 }; + let jan1 = ymd_serial(sys, y, 1, 1)?; + Ok((thursday - jan1).div_euclid(7) + 1) +} + +fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); + let s = day_arg(a, 0)?; + let t = to_int(opt_num(a, 1, 1.0)?)?; + let start = match t { + 1 | 17 => 0, + 2 | 11 => 1, + 12..=16 => t - 10, + 21 => return num_val(isoweek(sys, s)? as f64), + _ => return Err(CellError::Num), + } as usize; + let y = dt(sys, s as f64)?.year as i64; + let jan1 = ymd_serial(sys, y, 1, 1).unwrap_or(s.min(1)); + let offset = (weekday0(sys, jan1) + 7 - start) % 7; + num_val(((s - jan1 + offset as i64).div_euclid(7) + 1) as f64) +} + +fn isoweeknum(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(isoweek(c.date_system(), day_arg(a, 0)?)? as f64) +} + +fn months_arg(a: &[Arg], i: usize) -> R { + let m = to_int(num(a, i)?)?; + if m.abs() > 200_000 { + return Err(CellError::Num); + } + Ok(m) +} + +fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); + let d = dt(sys, day_arg(a, 0)? as f64)?; + let m = months_arg(a, 1)?; + let total = d.year as i64 * 12 + d.month as i64 - 1 + m; + let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1); + if !(1900..=9999).contains(&y) { + return Err(CellError::Num); + } + let dd = (d.day.max(1) as i64).min(days_in_month(y as i32, mo as u32) as i64); + num_val(ymd_serial(sys, y, mo, dd)? as f64) +} + +fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); + let d = dt(sys, day_arg(a, 0)? as f64)?; + let m = months_arg(a, 1)?; + let total = d.year as i64 * 12 + d.month as i64 - 1 + m; + let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1); + if !(1900..=9999).contains(&y) { + return Err(CellError::Num); + } + num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64) +} + +fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); + let s = day_arg(a, 0)?; + let e = day_arg(a, 1)?; + let unit = text(a, 2)?.to_ascii_uppercase(); + if s > e { + return Err(CellError::Num); + } + let d1 = dt(sys, s as f64)?; + let d2 = dt(sys, e as f64)?; + let months = (d2.year as i64 - d1.year as i64) * 12 + d2.month as i64 - d1.month as i64 - i64::from(d2.day < d1.day); + let r = match unit.as_str() { + "D" => e - s, + "M" => months, + "Y" => months / 12, + "YM" => months % 12, + "MD" => { + if d2.day >= d1.day { + (d2.day - d1.day) as i64 + } else { + let (py, pm) = if d2.month == 1 { (d2.year - 1, 12) } else { (d2.year, d2.month - 1) }; + days_in_month(py, pm) as i64 - d1.day as i64 + d2.day as i64 + } + } + "YD" => { + let later = (d1.month, d1.day) > (d2.month, d2.day); + let y = if later { d2.year - 1 } else { d2.year }; + let dd = d1.day.min(days_in_month(y, d1.month).max(1)); + let shifted = ymd_serial(sys, y as i64, d1.month as i64, dd as i64)?; + e - shifted + } + _ => return Err(CellError::Num), + }; + num_val(r as f64) +} + +fn parsed_text(a: &[Arg], c: &mut dyn Ctx) -> R<(f64, &'static str)> { + let v = scalar(arg(a, 0)?); + let Value::Text(t) = v else { + return Err(match v { + Value::Error(e) => e, + _ => CellError::Value, + }); + }; + let p = parse_input(&t, c.date_system()); + match (p.value, p.format) { + (Value::Number(n), Some(f)) if f.contains(['d', 'y', 'h']) => Ok((n, f)), + _ => Err(CellError::Value), + } +} + +fn datevalue(a: &[Arg], c: &mut dyn Ctx) -> R { + let (n, f) = parsed_text(a, c)?; + if !f.contains(['d', 'y']) { + return Err(CellError::Value); + } + num_val(n.floor()) +} + +fn timevalue(a: &[Arg], c: &mut dyn Ctx) -> R { + let (n, _) = parsed_text(a, c)?; + num_val(n - n.floor()) +} + +fn days(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val((day_arg(a, 0)? - day_arg(a, 1)?) as f64) +} + +fn last_of_feb(d: &DateTime) -> bool { + d.month == 2 && d.day == days_in_month(d.year, 2) +} + +fn days360(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); + let d1 = dt(sys, day_arg(a, 0)? as f64)?; + let d2 = dt(sys, day_arg(a, 1)? as f64)?; + let european = opt_bool(a, 2, false)?; + num_val(days360_core(&d1, &d2, european) as f64) +} + +fn days360_core(d1: &DateTime, d2: &DateTime, european: bool) -> i64 { + let (mut a, mut b) = (d1.day as i64, d2.day as i64); + if european { + a = a.min(30); + b = b.min(30); + } else { + if a == 31 || last_of_feb(d1) { + a = 30; + } + if b == 31 && a >= 30 { + b = 30; + } + } + (d2.year as i64 - d1.year as i64) * 360 + (d2.month as i64 - d1.month as i64) * 30 + b - a +} + +/// Weekend mask indexed by weekday (0 = Sunday). +fn weekend_mask(v: Option<&Arg>) -> R<[bool; 7]> { + let Some(arg) = v else { return Ok([true, false, false, false, false, false, true]) }; + match scalar(arg) { + Value::Empty if !arg.from_ref => Ok([true, false, false, false, false, false, true]), + Value::Text(t) => { + let b = t.as_bytes(); + if b.len() != 7 || !b.iter().all(|c| *c == b'0' || *c == b'1') || b.iter().all(|c| *c == b'1') { + return Err(CellError::Value); + } + // String order is Monday..Sunday. + let mut m = [false; 7]; + for (i, c) in b.iter().enumerate() { + m[(i + 1) % 7] = *c == b'1'; + } + Ok(m) + } + Value::Error(e) => Err(e), + other => { + let k = to_int(other.to_number()?)?; + let mut m = [false; 7]; + match k { + 1..=7 => { + m[((k + 5) % 7) as usize] = true; + m[((k + 6) % 7) as usize] = true; + } + 11..=17 => m[(k - 11) as usize] = true, + _ => return Err(CellError::Num), + } + Ok(m) + } + } +} + +fn holidays(v: Option<&Arg>) -> R> { + let mut out = Vec::new(); + if let Some(a) = v { + for x in as_array(&a.value).iter() { + match x { + Value::Empty => {} + Value::Error(e) => return Err(*e), + other => { + let n = other.to_number()?; + if !(0.0..MAX_SERIAL + 1.0).contains(&n) { + return Err(CellError::Num); + } + out.push(n.floor() as i64); + } + } + } + } + out.sort_unstable(); + out.dedup(); + Ok(out) +} + +/// Working days in [a, b] (a ≤ b) ignoring holidays. +fn count_workdays(sys: DateSystem, a: i64, b: i64, mask: &[bool; 7]) -> i64 { + let per_week = mask.iter().filter(|w| !**w).count() as i64; + let total = b - a + 1; + let weeks = total / 7; + let mut n = weeks * per_week; + let mut d = a + weeks * 7; + while d <= b { + if !mask[weekday0(sys, d)] { + n += 1; + } + d += 1; + } + n +} + +fn networkdays_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R { + let sys = c.date_system(); + let s = day_arg(a, 0)?; + let e = day_arg(a, 1)?; + let hs = holidays(hol)?; + let (lo, hi, sign) = if s <= e { (s, e, 1) } else { (e, s, -1) }; + let mut n = count_workdays(sys, lo, hi, &mask); + n -= hs.iter().filter(|&&h| h >= lo && h <= hi && !mask[weekday0(sys, h)]).count() as i64; + num_val((n * sign) as f64) +} + +fn networkdays(a: &[Arg], c: &mut dyn Ctx) -> R { + networkdays_core(a, c, weekend_mask(None)?, a.get(2)) +} + +fn networkdays_intl(a: &[Arg], c: &mut dyn Ctx) -> R { + let mask = weekend_mask(a.get(2))?; + networkdays_core(a, c, mask, a.get(3)) +} + +/// Moves `n` (> 0) working days from `start` in direction `dir`, ignoring holidays. +fn advance(sys: DateSystem, start: i64, n: i64, dir: i64, mask: &[bool; 7], per_week: i64) -> i64 { + let weeks = (n - 1) / per_week; + let mut cur = start + dir * 7 * weeks; + let mut rem = n - weeks * per_week; + while rem > 0 { + cur += dir; + if !mask[weekday0(sys, cur)] { + rem -= 1; + } + } + cur +} + +fn workday_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R { + let sys = c.date_system(); + let start = day_arg(a, 0)?; + let days = num(a, 1)?.trunc(); + if days.abs() > 1e7 { + return Err(CellError::Num); + } + let days = days as i64; + let hs = holidays(hol)?; + let per_week = mask.iter().filter(|w| !**w).count() as i64; + if per_week == 0 { + return Err(CellError::Value); + } + if days == 0 { + return num_val(start as f64); + } + let dir = days.signum(); + let mut cur = start; + let mut rem = days.abs(); + while rem > 0 { + let next = advance(sys, cur, rem, dir, &mask, per_week); + let (lo, hi) = if dir > 0 { (cur + 1, next) } else { (next, cur - 1) }; + rem = hs.iter().filter(|&&h| h >= lo && h <= hi && !mask[weekday0(sys, h)]).count() as i64; + cur = next; + if !(0..=MAX_SERIAL as i64).contains(&cur) { + return Err(CellError::Num); + } + } + num_val(cur as f64) +} + +fn workday(a: &[Arg], c: &mut dyn Ctx) -> R { + workday_core(a, c, weekend_mask(None)?, a.get(2)) +} + +fn workday_intl(a: &[Arg], c: &mut dyn Ctx) -> R { + let mask = weekend_mask(a.get(2))?; + workday_core(a, c, mask, a.get(3)) +} + +/// Excel's YEARFRAC for a basis (0–4) between two whole-day serials. +pub(crate) fn yearfrac_core(sys: DateSystem, s: i64, e: i64, basis: i64) -> R { + let (s, e) = if s <= e { (s, e) } else { (e, s) }; + let d1 = dt(sys, s as f64)?; + let d2 = dt(sys, e as f64)?; + let days = (e - s) as f64; + Ok(match basis { + 0 => { + let (mut a, mut b) = (d1.day as i64, d2.day as i64); + if a == 31 && b == 31 { + a = 30; + b = 30; + } else if a == 31 { + a = 30; + } else if a == 30 && b == 31 { + b = 30; + } else if last_of_feb(&d1) && last_of_feb(&d2) { + a = 30; + b = 30; + } else if last_of_feb(&d1) { + a = 30; + } + ((d2.year as i64 - d1.year as i64) * 360 + (d2.month as i64 - d1.month as i64) * 30 + b - a) as f64 / 360.0 + } + 1 => { + let within_year = d1.year == d2.year || (d2.year == d1.year + 1 && (d1.month > d2.month || (d1.month == d2.month && d1.day >= d2.day))); + if within_year { + let year_len = if (d1.year == d2.year && is_leap(d1.year)) || feb29_between(sys, s, e, &d1, &d2) || (d2.month == 2 && d2.day == 29) { + 366.0 + } else { + 365.0 + }; + days / year_len + } else { + let years = (d2.year - d1.year + 1) as f64; + let span = ymd_serial(sys, d2.year as i64 + 1, 1, 1).unwrap_or(e + 1) - ymd_serial(sys, d1.year as i64, 1, 1).unwrap_or(s); + days / (span as f64 / years) + } + } + 2 => days / 360.0, + 3 => days / 365.0, + 4 => days360_core(&d1, &d2, true) as f64 / 360.0, + _ => return Err(CellError::Num), + }) +} + +fn feb29_between(sys: DateSystem, s: i64, e: i64, d1: &DateTime, d2: &DateTime) -> bool { + (d1.year..=d2.year).any(|y| is_leap(y) && serial_from_ymd(sys, y as i64, 2, 29).is_some_and(|f| (f as i64) >= s && (f as i64) <= e)) +} + +fn yearfrac(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = day_arg(a, 0)?; + let e = day_arg(a, 1)?; + let basis = if has(a, 2) { to_int(num(a, 2)?)? } else { 0 }; + num_val(yearfrac_core(c.date_system(), s, e, basis)?) +} + +pub(crate) fn specs() -> Vec { + vec![ + f!("DATE", 3, 3, DateTime, S, "DATE(year, month, day)", "Builds a date serial number from a year, month and day.", date), + f!("TIME", 3, 3, DateTime, S, "TIME(hour, minute, second)", "Builds a time (a fraction of a day) from hours, minutes and seconds.", time), + f!("YEAR", 1, 1, DateTime, S, "YEAR(serial_number)", "Returns the year of a date.", year), + f!("MONTH", 1, 1, DateTime, S, "MONTH(serial_number)", "Returns the month (1-12) of a date.", month), + f!("DAY", 1, 1, DateTime, S, "DAY(serial_number)", "Returns the day of the month of a date.", day), + f!("HOUR", 1, 1, DateTime, S, "HOUR(serial_number)", "Returns the hour (0-23) of a time.", hour), + f!("MINUTE", 1, 1, DateTime, S, "MINUTE(serial_number)", "Returns the minute (0-59) of a time.", minute), + f!("SECOND", 1, 1, DateTime, S, "SECOND(serial_number)", "Returns the second (0-59) of a time.", second), + f!(volatile; "NOW", 0, 0, DateTime, S, "NOW()", "Returns the current date and time.", now), + f!(volatile; "TODAY", 0, 0, DateTime, S, "TODAY()", "Returns the current date.", today), + f!("WEEKDAY", 1, 2, DateTime, S, "WEEKDAY(serial_number, [return_type])", "Returns the day of the week of a date as a number.", weekday), + f!("WEEKNUM", 1, 2, DateTime, S, "WEEKNUM(serial_number, [return_type])", "Returns the week number of a date within its year.", weeknum), + f!("ISOWEEKNUM", 1, 1, DateTime, S, "ISOWEEKNUM(date)", "Returns the ISO 8601 week number of a date.", isoweeknum), + f!("EDATE", 2, 2, DateTime, S, "EDATE(start_date, months)", "Returns the date a number of months before or after a date.", edate), + f!("EOMONTH", 2, 2, DateTime, S, "EOMONTH(start_date, months)", "Returns the last day of the month a number of months away.", eomonth), + f!("DATEDIF", 3, 3, DateTime, S, "DATEDIF(start_date, end_date, unit)", "Counts whole years, months or days between two dates.", datedif), + f!("DATEVALUE", 1, 1, DateTime, S, "DATEVALUE(date_text)", "Converts a date written as text into a date serial number.", datevalue), + f!("TIMEVALUE", 1, 1, DateTime, S, "TIMEVALUE(time_text)", "Converts a time written as text into a fraction of a day.", timevalue), + f!("DAYS", 2, 2, DateTime, S, "DAYS(end_date, start_date)", "Returns the number of days between two dates.", days), + f!( + "DAYS360", + 2, + 3, + DateTime, + S, + "DAYS360(start_date, end_date, [method])", + "Counts days between two dates on a 360-day year (US or European method).", + days360 + ), + f!( + "NETWORKDAYS", + 2, + 3, + DateTime, + ND, + "NETWORKDAYS(start_date, end_date, [holidays])", + "Counts working days (Monday to Friday) between two dates, excluding holidays.", + networkdays + ), + f!( + "NETWORKDAYS.INTL", + 2, + 4, + DateTime, + NDI, + "NETWORKDAYS.INTL(start_date, end_date, [weekend], [holidays])", + "Counts working days between two dates with a custom weekend, excluding holidays.", + networkdays_intl + ), + f!( + "WORKDAY", + 2, + 3, + DateTime, + ND, + "WORKDAY(start_date, days, [holidays])", + "Returns the date a number of working days before or after a date.", + workday + ), + f!( + "WORKDAY.INTL", + 2, + 4, + DateTime, + NDI, + "WORKDAY.INTL(start_date, days, [weekend], [holidays])", + "Returns the date a number of working days away with a custom weekend.", + workday_intl + ), + f!( + "YEARFRAC", + 2, + 3, + DateTime, + S, + "YEARFRAC(start_date, end_date, [basis])", + "Returns the fraction of a year between two dates under a day-count basis.", + yearfrac + ), + ] +} + +#[cfg(test)] +mod tests { + use sheetcraft_core::{CellError, Value}; + + use crate::util::testutil::*; + + fn d(y: f64, m: f64, dd: f64) -> f64 { + match ev("DATE", vec![n(y), n(m), n(dd)]) { + Value::Number(x) => x, + v => panic!("{v:?}"), + } + } + + #[test] + fn construct() { + assert_eq!(d(2026.0, 10.0, 7.0), 46302.0); + assert_eq!(d(2020.0, 13.0, 1.0), 44197.0); + assert_eq!(d(2020.0, 3.0, 0.0), 43890.0); + assert_eq!(d(120.0, 1.0, 1.0), 43831.0); + assert_eq!(d(2020.0, 1.0, -1.0), 43829.0); + is_err(ev("DATE", vec![n(10000.0), n(1.0), n(1.0)]), CellError::Num); + is_err(ev("DATE", vec![n(-1.0), n(1.0), n(1.0)]), CellError::Num); + is_err(ev("DATE", vec![n(1900.0), n(1.0), n(-5.0)]), CellError::Num); + close(ev("TIME", vec![n(12.0), n(0.0), n(0.0)]), 0.5); + close(ev("TIME", vec![n(25.0), n(0.0), n(0.0)]), 1.0 / 24.0); + is_err(ev("TIME", vec![n(0.0), n(-1.0), n(0.0)]), CellError::Num); + } + + #[test] + fn time_negative() { + is_err(ev("TIME", vec![n(0.0), n(0.0), n(-1.0)]), CellError::Num); + close(ev("TIME", vec![n(1.0), n(-30.0), n(0.0)]), 1.0 / 48.0); + } + + #[test] + fn parts() { + close(ev("YEAR", vec![n(46302.75)]), 2026.0); + close(ev("MONTH", vec![n(46302.75)]), 10.0); + close(ev("DAY", vec![n(46302.75)]), 7.0); + close(ev("HOUR", vec![n(46302.75)]), 18.0); + close(ev("MINUTE", vec![n(0.5 + 61.0 / 1440.0)]), 1.0); + close(ev("SECOND", vec![n(12.5 / 86400.0)]), 13.0); + close(ev("HOUR", vec![n(0.999_999_9)]), 0.0); + close(ev("YEAR", vec![t("2020-05-06")]), 2020.0); + close(ev("DAY", vec![n(0.0)]), 0.0); + close(ev("MONTH", vec![n(60.0)]), 2.0); + close(ev("DAY", vec![n(60.0)]), 29.0); + is_err(ev("YEAR", vec![n(-1.0)]), CellError::Num); + is_err(ev("YEAR", vec![t("abc")]), CellError::Value); + close(ev("NOW", vec![]), 46302.5); + close(ev("TODAY", vec![]), 46302.0); + } + + #[test] + fn weekdays() { + // 2026-10-07 is a Wednesday. + close(ev("WEEKDAY", vec![n(46302.0)]), 4.0); + close(ev("WEEKDAY", vec![n(46302.0), n(2.0)]), 3.0); + close(ev("WEEKDAY", vec![n(46302.0), n(3.0)]), 2.0); + close(ev("WEEKDAY", vec![n(46302.0), n(13.0)]), 1.0); + close(ev("WEEKDAY", vec![n(46302.0), n(16.0)]), 5.0); + close(ev("WEEKDAY", vec![n(46302.0), n(17.0)]), 4.0); + is_err(ev("WEEKDAY", vec![n(46302.0), n(4.0)]), CellError::Num); + close(ev("WEEKDAY", vec![n(1.0)]), 1.0); + // 2024-01-01 Monday. + let jan1 = d(2024.0, 1.0, 1.0); + close(ev("WEEKNUM", vec![n(jan1)]), 1.0); + close(ev("WEEKNUM", vec![n(d(2024.0, 1.0, 7.0))]), 2.0); // Sunday starts week 2 + close(ev("WEEKNUM", vec![n(d(2024.0, 1.0, 7.0)), n(2.0)]), 1.0); + close(ev("WEEKNUM", vec![n(d(2026.0, 10.0, 7.0))]), 41.0); + close(ev("WEEKNUM", vec![n(d(2021.0, 1.0, 1.0)), n(21.0)]), 53.0); + close(ev("ISOWEEKNUM", vec![n(d(2021.0, 1.0, 1.0))]), 53.0); + close(ev("ISOWEEKNUM", vec![n(d(2026.0, 10.0, 7.0))]), 41.0); + close(ev("ISOWEEKNUM", vec![n(d(2024.0, 12.0, 30.0))]), 1.0); + is_err(ev("WEEKNUM", vec![n(1.0), n(3.0)]), CellError::Num); + } + + #[test] + fn month_math() { + close(ev("EDATE", vec![n(d(2020.0, 1.0, 31.0)), n(1.0)]), d(2020.0, 2.0, 29.0)); + close(ev("EDATE", vec![n(d(2020.0, 3.0, 15.0)), n(-14.0)]), d(2019.0, 1.0, 15.0)); + close(ev("EDATE", vec![n(d(2020.0, 3.0, 15.5)), n(1.9)]), d(2020.0, 4.0, 15.0)); + close(ev("EOMONTH", vec![n(d(2020.0, 1.0, 15.0)), n(1.0)]), d(2020.0, 2.0, 29.0)); + close(ev("EOMONTH", vec![n(d(2020.0, 1.0, 15.0)), n(-1.0)]), d(2019.0, 12.0, 31.0)); + close(ev("EOMONTH", vec![n(d(2020.0, 1.0, 15.0)), n(0.0)]), d(2020.0, 1.0, 31.0)); + is_err(ev("EOMONTH", vec![n(1.0), n(-12.0)]), CellError::Num); + is_err(ev("EDATE", vec![n(1.0), n(1e12)]), CellError::Num); + } + + #[test] + fn datedif_units() { + let s = n(d(2001.0, 6.0, 1.0)); + let e = n(d(2002.0, 8.0, 15.0)); + close(ev("DATEDIF", vec![s.clone(), e.clone(), t("Y")]), 1.0); + close(ev("DATEDIF", vec![s.clone(), e.clone(), t("M")]), 14.0); + close(ev("DATEDIF", vec![s.clone(), e.clone(), t("D")]), 440.0); + close(ev("DATEDIF", vec![s.clone(), e.clone(), t("YM")]), 2.0); + close(ev("DATEDIF", vec![s.clone(), e.clone(), t("MD")]), 14.0); + close(ev("DATEDIF", vec![s.clone(), e.clone(), t("yd")]), 75.0); + close(ev("DATEDIF", vec![n(d(2020.0, 1.0, 31.0)), n(d(2020.0, 3.0, 1.0)), t("M")]), 1.0); + close(ev("DATEDIF", vec![n(d(2020.0, 1.0, 31.0)), n(d(2020.0, 3.0, 1.0)), t("MD")]), -1.0); + is_err(ev("DATEDIF", vec![e.clone(), s.clone(), t("D")]), CellError::Num); + is_err(ev("DATEDIF", vec![s, e, t("X")]), CellError::Num); + } + + #[test] + fn text_conversion() { + close(ev("DATEVALUE", vec![t("2020-01-01")]), 43831.0); + close(ev("DATEVALUE", vec![t("1/2/2020 10:00")]), 43832.0); + close(ev("DATEVALUE", vec![t("4-Mar-2026")]), d(2026.0, 3.0, 4.0)); + is_err(ev("DATEVALUE", vec![t("hello")]), CellError::Value); + is_err(ev("DATEVALUE", vec![t("123")]), CellError::Value); + is_err(ev("DATEVALUE", vec![n(43831.0)]), CellError::Value); + close(ev("TIMEVALUE", vec![t("6:00 PM")]), 0.75); + close(ev("TIMEVALUE", vec![t("1/2/2020 12:00")]), 0.5); + is_err(ev("TIMEVALUE", vec![t("x")]), CellError::Value); + } + + #[test] + fn day_counts() { + close(ev("DAYS", vec![n(d(2021.0, 3.0, 15.0)), n(d(2021.0, 2.0, 1.0))]), 42.0); + close(ev("DAYS", vec![t("2021-02-01"), t("2021-03-15")]), -42.0); + close(ev("DAYS360", vec![n(d(2011.0, 1.0, 30.0)), n(d(2011.0, 12.0, 31.0))]), 330.0); + close(ev("DAYS360", vec![n(d(2011.0, 1.0, 29.0)), n(d(2011.0, 12.0, 31.0))]), 332.0); + close(ev("DAYS360", vec![n(d(2011.0, 1.0, 29.0)), n(d(2011.0, 12.0, 31.0)), b(true)]), 331.0); + close(ev("DAYS360", vec![n(d(2011.0, 1.0, 30.0)), n(d(2011.0, 12.0, 31.0)), b(true)]), 330.0); + close(ev("DAYS360", vec![n(d(2011.0, 2.0, 28.0)), n(d(2011.0, 3.0, 31.0))]), 30.0); + close(ev("DAYS360", vec![n(d(2011.0, 1.0, 1.0)), n(d(2011.0, 1.0, 31.0))]), 30.0); + } + + #[test] + fn working_days() { + let s = d(2012.0, 10.0, 1.0); + let e = d(2013.0, 3.0, 1.0); + close(ev("NETWORKDAYS", vec![n(s), n(e)]), 110.0); + close(ev("NETWORKDAYS", vec![n(s), n(e), av(col(&[d(2012.0, 11.0, 22.0)]))]), 109.0); + close(ev("NETWORKDAYS", vec![n(s), n(e), av(col(&[d(2012.0, 11.0, 22.0), d(2012.0, 12.0, 4.0), d(2013.0, 1.0, 21.0)]))]), 107.0); + close(ev("NETWORKDAYS", vec![n(e), n(s)]), -110.0); + close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 1.0, 31.0))]), 22.0); + is_err(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 2.0, 1.0)), n(-1.0)]), CellError::Num); + close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 2.0, 1.0)), n(7.0)]), 24.0); + close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 2.0, 1.0)), t("0000110")]), 24.0); + close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 1.0, 31.0)), n(11.0)]), 26.0); + is_err(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), t("1111111")]), CellError::Value); + is_err(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), n(8.0)]), CellError::Num); + // WORKDAY + close(ev("WORKDAY", vec![n(d(2008.0, 10.0, 1.0)), n(151.0)]), d(2009.0, 4.0, 30.0)); + let hol = av(col(&[d(2008.0, 11.0, 26.0), d(2008.0, 12.0, 4.0), d(2009.0, 1.0, 21.0)])); + close(ev("WORKDAY", vec![n(d(2008.0, 10.0, 1.0)), n(151.0), hol]), d(2009.0, 5.0, 5.0)); + close(ev("WORKDAY", vec![n(d(2026.0, 10.0, 9.0)), n(1.0)]), d(2026.0, 10.0, 12.0)); + close(ev("WORKDAY", vec![n(d(2026.0, 10.0, 12.0)), n(-1.0)]), d(2026.0, 10.0, 9.0)); + close(ev("WORKDAY", vec![n(d(2026.0, 10.0, 10.0)), n(0.0)]), d(2026.0, 10.0, 10.0)); + is_err(ev("WORKDAY.INTL", vec![n(d(2012.0, 1.0, 1.0)), n(30.0), n(0.0)]), CellError::Num); + close(ev("WORKDAY.INTL", vec![n(d(2012.0, 1.0, 1.0)), n(90.0), n(11.0)]), d(2012.0, 4.0, 14.0)); + close(ev("WORKDAY.INTL", vec![n(d(2012.0, 1.0, 1.0)), n(30.0), n(17.0)]), d(2012.0, 2.0, 5.0)); + is_err(ev("WORKDAY", vec![n(d(2026.0, 1.0, 1.0)), n(1e9)]), CellError::Num); + is_err(ev("WORKDAY", vec![n(d(2026.0, 1.0, 1.0)), n(3e6)]), CellError::Num); + } + + #[test] + fn yearfrac_bases() { + let s = n(d(2012.0, 1.0, 1.0)); + let e = n(d(2012.0, 7.0, 30.0)); + close(ev("YEARFRAC", vec![s.clone(), e.clone()]), 0.580_555_555_555_555_6); + close(ev("YEARFRAC", vec![s.clone(), e.clone(), n(1.0)]), 0.576_502_732_240_437_2); + close(ev("YEARFRAC", vec![s.clone(), e.clone(), n(2.0)]), 0.586_111_111_111_111_1); + close(ev("YEARFRAC", vec![s.clone(), e.clone(), n(3.0)]), 0.578_082_191_780_821_9); + close(ev("YEARFRAC", vec![s.clone(), e.clone(), n(4.0)]), 0.580_555_555_555_555_6); + close(ev("YEARFRAC", vec![e.clone(), s.clone(), n(1.0)]), 0.576_502_732_240_437_2); + is_err(ev("YEARFRAC", vec![s.clone(), e, n(5.0)]), CellError::Num); + // Multi-year actual/actual uses the average year length. + close(ev("YEARFRAC", vec![n(d(2000.0, 1.0, 1.0)), n(d(2003.0, 1.0, 1.0)), n(1.0)]), 1096.0 / (1461.0 / 4.0)); + close(ev("YEARFRAC", vec![n(d(2011.0, 2.0, 28.0)), n(d(2012.0, 2.0, 29.0)), n(0.0)]), 1.0); + close(ev("YEARFRAC", vec![n(d(2011.0, 3.0, 1.0)), n(d(2012.0, 2.0, 29.0)), n(1.0)]), 365.0 / 366.0); + close(ev("YEARFRAC", vec![s, n(d(2012.0, 1.0, 1.0))]), 0.0); + } + + #[test] + fn date_lifting() { + let v = ev("YEAR", vec![av(row(&[43831.0, 46302.0]))]); + assert_eq!(rows_of(&v), vec![vec![nv(2020.0), nv(2026.0)]]); + } + + #[test] + fn system_1904() { + let mut ctx = TestCtx { sys: sheetcraft_core::DateSystem::D1904, ..TestCtx::default() }; + let spec = crate::lookup("DATE").unwrap(); + close(crate::call(spec, &[n(2000.0), n(1.0), n(1.0)], &mut ctx), 35064.0); + let spec = crate::lookup("WEEKDAY").unwrap(); + close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0); + let spec = crate::lookup("YEAR").unwrap(); + close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0); + } +} diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs new file mode 100644 index 00000000..77b8ea29 --- /dev/null +++ b/crates/functions/src/dynamic.rs @@ -0,0 +1,1228 @@ +//! Dynamic-array functions: FILTER, SORT, SORTBY, UNIQUE, SEQUENCE, RANDARRAY, VSTACK, HSTACK, +//! TOROW, TOCOL, WRAPROWS, WRAPCOLS, TAKE, DROP, CHOOSEROWS, CHOOSECOLS, EXPAND, GROUPBY, +//! PIVOTBY. +//! +//! GROUPBY / PIVOTBY take their aggregation as a function name ("SUM", "AVERAGE"…) or a +//! SUBTOTAL-style code (1–11): the host resolves eta-reduced lambdas such as `SUM` to the name +//! text before calling. Custom LAMBDA aggregations are not supported here. + +use std::cmp::Ordering; +use std::collections::HashMap; + +use sheetcraft_core::{Array, CellError, Value, sort_compare}; + +use crate::util::{A, MAX_CELLS, R, arg, array_val, as_array, has, num, opt_bool, scalar}; +use crate::{Arg, Ctx, FnSpec, VAR}; + +// --------------------------------------------------------------------------------------------- +// Helpers + +/// Argument `i` as an owned array; error values propagate. +fn arr_arg(args: &[Arg], i: usize) -> R { + let v = &arg(args, i)?.value; + if let Value::Error(e) = v { + return Err(*e); + } + Ok(as_array(v).into_owned()) +} + +fn rows_of(a: &Array) -> Vec> { + (0..a.rows).map(|r| (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect()).collect() +} + +/// Builds an array from rows (all rows padded to the widest with `pad`). Empty → `#CALC!`. +fn from_rows(rows: Vec>, pad: &Value) -> R { + let h = rows.len(); + let w = rows.iter().map(Vec::len).max().unwrap_or(0); + if h == 0 || w == 0 { + return Err(CellError::Calc); + } + if h.saturating_mul(w) > MAX_CELLS { + return Err(CellError::Num); + } + let mut data = Vec::with_capacity(h * w); + for mut r in rows { + r.resize(w, pad.clone()); + data.extend(r); + } + array_val(h, w, data) +} + +/// An optional integer argument where a missing or blank argument means `None`. +fn opt_count(args: &[Arg], i: usize) -> R> { + if !has(args, i) { + return Ok(None); + } + let n = num(args, i)?; + if !n.is_finite() || n.abs() > 1e15 { + return Err(CellError::Num); + } + Ok(Some(n.trunc() as i64)) +} + +/// Pad value for WRAPROWS/EXPAND…: `#N/A` when omitted. +fn pad_arg(args: &[Arg], i: usize) -> Value { + match args.get(i) { + Some(a) if has(args, i) => scalar(a), + _ => Value::Error(CellError::NA), + } +} + +/// Hashable key for case-insensitive equality of values. +fn key_of(v: &Value) -> String { + match v { + Value::Empty => "e".into(), + Value::Number(n) => format!("n{}", if *n == 0.0 { 0u64 } else { n.to_bits() }), + Value::Text(t) => format!("t{}", t.to_lowercase()), + Value::Bool(b) => format!("b{b}"), + Value::Error(e) => format!("x{}", e.code()), + Value::Array(a) => key_of(a.data.first().unwrap_or(&Value::Empty)), + } +} + +fn row_key(r: &[Value]) -> String { + let mut s = String::new(); + for v in r { + s.push_str(&key_of(v)); + s.push('\u{1}'); + } + s +} + +fn truthy(v: &Value) -> R { + match v { + Value::Error(e) => Err(*e), + Value::Empty => Ok(false), + Value::Text(_) => Err(CellError::Value), + other => other.to_bool(), + } +} + +// --------------------------------------------------------------------------------------------- +// FILTER / SORT / SORTBY / UNIQUE + +fn filter(args: &[Arg], _c: &mut dyn Ctx) -> R { + let a = arr_arg(args, 0)?; + let inc = arr_arg(args, 1)?; + let keep_rows = inc.cols == 1 && inc.rows == a.rows; + let keep_cols = !keep_rows && inc.rows == 1 && inc.cols == a.cols; + if !keep_rows && !keep_cols { + return Err(CellError::Value); + } + let flags: Vec = inc.data.iter().map(truthy).collect::>()?; + let grid = rows_of(&a); + let out: Vec> = if keep_rows { + grid.into_iter().zip(flags.iter()).filter(|(_, f)| **f).map(|(r, _)| r).collect() + } else { + grid.into_iter().map(|r| r.into_iter().zip(flags.iter()).filter(|(_, f)| **f).map(|(v, _)| v).collect::>()).collect() + }; + if out.is_empty() || out.iter().all(Vec::is_empty) { + if has(args, 2) { + return Ok(arg(args, 2)?.value.clone()); + } + return Err(CellError::Calc); + } + from_rows(out, &Value::Empty) +} + +/// Compares two key vectors with per-key orders (1 ascending, -1 descending). +fn cmp_keys(a: &[Value], b: &[Value], orders: &[i64]) -> Ordering { + for (i, ord) in orders.iter().enumerate() { + let (Some(x), Some(y)) = (a.get(i), b.get(i)) else { continue }; + let o = sort_compare(x, y); + let o = if *ord < 0 { + // Blanks stay last even when descending. + match (x, y) { + (Value::Empty, Value::Empty) => Ordering::Equal, + (Value::Empty, _) => Ordering::Greater, + (_, Value::Empty) => Ordering::Less, + _ => o.reverse(), + } + } else { + o + }; + if o != Ordering::Equal { + return o; + } + } + Ordering::Equal +} + +fn int_list(v: &Value) -> R> { + let a = as_array(v); + a.data + .iter() + .map(|x| { + let n = x.to_number()?; + if !n.is_finite() || n.abs() > 1e15 { Err(CellError::Value) } else { Ok(n.trunc() as i64) } + }) + .collect() +} + +fn sort(args: &[Arg], _c: &mut dyn Ctx) -> R { + let a = arr_arg(args, 0)?; + let by_col = opt_bool(args, 3, false)?; + let a = if by_col { a.transpose() } else { a }; + let idx = if has(args, 1) { int_list(&arg(args, 1)?.value)? } else { vec![1] }; + let ord = if has(args, 2) { int_list(&arg(args, 2)?.value)? } else { vec![1] }; + if idx.is_empty() || idx.iter().any(|&i| i < 1 || i as usize > a.cols) || ord.iter().any(|&o| o != 1 && o != -1) { + return Err(CellError::Value); + } + if ord.len() > 1 && ord.len() != idx.len() { + return Err(CellError::Value); + } + let orders: Vec = (0..idx.len()).map(|i| *ord.get(i).or(ord.first()).unwrap_or(&1)).collect(); + let mut lines: Vec<(Vec, Vec)> = + rows_of(&a).into_iter().map(|r| (idx.iter().map(|&i| r.get(i as usize - 1).cloned().unwrap_or(Value::Empty)).collect(), r)).collect(); + lines.sort_by(|x, y| cmp_keys(&x.0, &y.0, &orders)); + let out = Array::new(a.rows, a.cols, lines.into_iter().flat_map(|(_, r)| r).collect()).ok_or(CellError::Calc)?; + Ok(Value::from(if by_col { out.transpose() } else { out })) +} + +fn sortby(args: &[Arg], _c: &mut dyn Ctx) -> R { + let a = arr_arg(args, 0)?; + let mut keys: Vec = Vec::new(); + let mut orders: Vec = Vec::new(); + let mut i = 1; + while i < args.len() { + keys.push(arr_arg(args, i)?); + let o = if has(args, i + 1) { num(args, i + 1)?.trunc() as i64 } else { 1 }; + if o != 1 && o != -1 { + return Err(CellError::Value); + } + orders.push(o); + i += 2; + } + let first = keys.first().ok_or(CellError::Value)?; + let by_rows = first.cols == 1 && first.rows == a.rows; + let by_cols = !by_rows && first.rows == 1 && first.cols == a.cols; + if !by_rows && !by_cols { + return Err(CellError::Value); + } + for k in &keys { + let ok = if by_rows { k.cols == 1 && k.rows == a.rows } else { k.rows == 1 && k.cols == a.cols }; + if !ok { + return Err(CellError::Value); + } + } + let base = if by_rows { a } else { a.transpose() }; + let mut lines: Vec<(Vec, Vec)> = rows_of(&base) + .into_iter() + .enumerate() + .map(|(r, row)| (keys.iter().map(|k| k.data.get(r).cloned().unwrap_or(Value::Empty)).collect(), row)) + .collect(); + lines.sort_by(|x, y| cmp_keys(&x.0, &y.0, &orders)); + let out = Array::new(base.rows, base.cols, lines.into_iter().flat_map(|(_, r)| r).collect()).ok_or(CellError::Calc)?; + Ok(Value::from(if by_rows { out } else { out.transpose() })) +} + +fn unique(args: &[Arg], _c: &mut dyn Ctx) -> R { + let a = arr_arg(args, 0)?; + let by_col = opt_bool(args, 1, false)?; + let once = opt_bool(args, 2, false)?; + let base = if by_col { a.transpose() } else { a }; + let lines = rows_of(&base); + let mut counts: HashMap = HashMap::new(); + for l in &lines { + *counts.entry(row_key(l)).or_insert(0) += 1; + } + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for l in lines { + let k = row_key(&l); + let c = counts.get(&k).copied().unwrap_or(0); + if once && c != 1 { + continue; + } + if seen.insert(k) { + out.push(l); + } + } + if out.is_empty() { + return Err(CellError::Calc); + } + let v = from_rows(out, &Value::Empty)?; + if by_col && let Value::Array(arr) = &v { + return Ok(Value::from(arr.transpose())); + } + Ok(v) +} + +// --------------------------------------------------------------------------------------------- +// SEQUENCE / RANDARRAY + +/// Result dimensions for SEQUENCE/RANDARRAY: blank means 1; 0 → `#CALC!`; negative → `#VALUE!`. +fn dims(args: &[Arg], ri: usize, ci: usize) -> R<(usize, usize)> { + let r = if has(args, ri) { num(args, ri)?.trunc() } else { 1.0 }; + let c = if has(args, ci) { num(args, ci)?.trunc() } else { 1.0 }; + if r < 0.0 || c < 0.0 || !r.is_finite() || !c.is_finite() { + return Err(CellError::Value); + } + if r == 0.0 || c == 0.0 { + return Err(CellError::Calc); + } + if r > 1_048_576.0 || c > 16_384.0 || r * c > MAX_CELLS as f64 { + return Err(CellError::Num); + } + Ok((r as usize, c as usize)) +} + +fn sequence(args: &[Arg], _c: &mut dyn Ctx) -> R { + let (r, c) = dims(args, 0, 1)?; + let start = if has(args, 2) { num(args, 2)? } else { 1.0 }; + let step = if has(args, 3) { num(args, 3)? } else { 1.0 }; + let data = (0..r * c).map(|i| Value::number(start + step * i as f64)).collect(); + array_val(r, c, data) +} + +fn randarray(args: &[Arg], ctx: &mut dyn Ctx) -> R { + let (r, c) = dims(args, 0, 1)?; + let lo = if has(args, 2) { num(args, 2)? } else { 0.0 }; + let hi = if has(args, 3) { num(args, 3)? } else { 1.0 }; + let whole = if has(args, 4) { opt_bool(args, 4, false)? } else { false }; + if lo > hi { + return Err(CellError::Value); + } + let mut data = Vec::with_capacity(r * c); + if whole { + let (lo, hi) = (lo.ceil(), hi.floor()); + if lo > hi { + return Err(CellError::Value); + } + for _ in 0..r * c { + let x = (lo + (ctx.random() * (hi - lo + 1.0)).floor()).min(hi); + data.push(Value::number(x)); + } + } else { + for _ in 0..r * c { + data.push(Value::number(lo + ctx.random() * (hi - lo))); + } + } + array_val(r, c, data) +} + +// --------------------------------------------------------------------------------------------- +// VSTACK / HSTACK / TOROW / TOCOL / WRAPROWS / WRAPCOLS + +fn vstack(args: &[Arg], _c: &mut dyn Ctx) -> R { + let mut rows = Vec::new(); + let mut total = 0usize; + for i in 0..args.len() { + let a = arr_arg(args, i)?; + total = total.saturating_add(a.data.len()); + if total > MAX_CELLS { + return Err(CellError::Num); + } + rows.extend(rows_of(&a)); + } + from_rows(rows, &Value::Error(CellError::NA)) +} + +fn hstack(args: &[Arg], _c: &mut dyn Ctx) -> R { + let mut arrays = Vec::new(); + let mut h = 0usize; + let mut total = 0usize; + for i in 0..args.len() { + let a = arr_arg(args, i)?; + h = h.max(a.rows); + total = total.saturating_add(a.data.len()); + if total > MAX_CELLS { + return Err(CellError::Num); + } + arrays.push(a); + } + let mut rows = vec![Vec::new(); h]; + for a in &arrays { + for (r, row) in rows.iter_mut().enumerate() { + for c in 0..a.cols { + row.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA))); + } + } + } + from_rows(rows, &Value::Error(CellError::NA)) +} + +/// Flattened values for TOROW/TOCOL with the ignore filter applied. +fn flat_filtered(args: &[Arg]) -> R> { + let a = arr_arg(args, 0)?; + let ignore = if has(args, 1) { num(args, 1)?.trunc() as i64 } else { 0 }; + if !(0..=3).contains(&ignore) { + return Err(CellError::Value); + } + let by_col = opt_bool(args, 2, false)?; + let a = if by_col { a.transpose() } else { a }; + Ok(a.data + .into_iter() + .filter(|v| match v { + Value::Empty => ignore & 1 == 0, + Value::Error(_) => ignore & 2 == 0, + _ => true, + }) + .collect()) +} + +fn torow(args: &[Arg], _c: &mut dyn Ctx) -> R { + let v = flat_filtered(args)?; + if v.is_empty() { + return Err(CellError::Calc); + } + array_val(1, v.len(), v) +} + +fn tocol(args: &[Arg], _c: &mut dyn Ctx) -> R { + let v = flat_filtered(args)?; + if v.is_empty() { + return Err(CellError::Calc); + } + array_val(v.len(), 1, v) +} + +fn wrap(args: &[Arg], rows: bool) -> R { + let a = arr_arg(args, 0)?; + if a.rows != 1 && a.cols != 1 { + return Err(CellError::Value); + } + let n = num(args, 1)?.trunc(); + if n < 1.0 || !n.is_finite() { + return Err(CellError::Num); + } + let pad = pad_arg(args, 2); + let len = a.data.len(); + let n = (n as usize).min(len.max(1)); + let lines = len.div_ceil(n); + let mut out = Vec::with_capacity(lines); + for l in 0..lines { + let mut line: Vec = a.data.iter().skip(l * n).take(n).cloned().collect(); + line.resize(n, pad.clone()); + out.push(line); + } + let v = from_rows(out, &pad)?; + if !rows && let Value::Array(arr) = &v { + return Ok(Value::from(arr.transpose())); + } + Ok(v) +} + +fn wraprows(args: &[Arg], _c: &mut dyn Ctx) -> R { + wrap(args, true) +} + +fn wrapcols(args: &[Arg], _c: &mut dyn Ctx) -> R { + wrap(args, false) +} + +// --------------------------------------------------------------------------------------------- +// TAKE / DROP / CHOOSEROWS / CHOOSECOLS / EXPAND + +/// Range of kept indices for TAKE (`take = true`) or DROP along an axis of length `len`. +fn span(len: usize, n: Option, take: bool) -> R<(usize, usize)> { + let Some(n) = n else { return Ok((0, len)) }; + let k = (n.unsigned_abs() as usize).min(len); + if take { + if n == 0 { + return Err(CellError::Calc); + } + Ok(if n > 0 { (0, k) } else { (len - k, len) }) + } else { + let r = if n >= 0 { (k, len) } else { (0, len - k) }; + if r.0 >= r.1 { + return Err(CellError::Calc); + } + Ok(r) + } +} + +fn take_drop(args: &[Arg], take: bool) -> R { + let a = arr_arg(args, 0)?; + let (r0, r1) = span(a.rows, opt_count(args, 1)?, take)?; + let (c0, c1) = span(a.cols, opt_count(args, 2)?, take)?; + let rows: Vec> = (r0..r1).map(|r| (c0..c1).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect()).collect(); + from_rows(rows, &Value::Empty) +} + +fn take_fn(args: &[Arg], _c: &mut dyn Ctx) -> R { + take_drop(args, true) +} + +fn drop_fn(args: &[Arg], _c: &mut dyn Ctx) -> R { + take_drop(args, false) +} + +fn choose_lines(args: &[Arg], rows: bool) -> R { + let a = arr_arg(args, 0)?; + let base = if rows { a } else { a.transpose() }; + let grid = rows_of(&base); + let mut out = Vec::new(); + for i in 1..args.len() { + for k in int_list(&arg(args, i)?.value)? { + let len = grid.len() as i64; + let idx = if k > 0 && k <= len { + k - 1 + } else if k < 0 && -k <= len { + len + k + } else { + return Err(CellError::Value); + }; + out.push(grid.get(idx as usize).cloned().ok_or(CellError::Value)?); + if out.len().saturating_mul(base.cols) > MAX_CELLS { + return Err(CellError::Num); + } + } + } + let v = from_rows(out, &Value::Empty)?; + if !rows && let Value::Array(arr) = &v { + return Ok(Value::from(arr.transpose())); + } + Ok(v) +} + +fn chooserows(args: &[Arg], _c: &mut dyn Ctx) -> R { + choose_lines(args, true) +} + +fn choosecols(args: &[Arg], _c: &mut dyn Ctx) -> R { + choose_lines(args, false) +} + +fn expand(args: &[Arg], _c: &mut dyn Ctx) -> R { + let a = arr_arg(args, 0)?; + let r = opt_count(args, 1)?.unwrap_or(a.rows as i64); + let c = opt_count(args, 2)?.unwrap_or(a.cols as i64); + if r < a.rows as i64 || c < a.cols as i64 { + return Err(CellError::Value); + } + if r > 1_048_576 || c > 16_384 || (r as usize).saturating_mul(c as usize) > MAX_CELLS { + return Err(CellError::Num); + } + let pad = pad_arg(args, 3); + let (r, c) = (r as usize, c as usize); + let mut data = Vec::with_capacity(r * c); + for i in 0..r { + for j in 0..c { + data.push(a.get(i, j).cloned().unwrap_or_else(|| pad.clone())); + } + } + array_val(r, c, data) +} + +// --------------------------------------------------------------------------------------------- +// GROUPBY / PIVOTBY + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Agg { + Sum, + Average, + Count, + CountA, + Max, + Min, + Product, + Median, + StdevS, + StdevP, + VarS, + VarP, +} + +fn parse_agg(v: &Value) -> R { + match v { + Value::Error(e) => Err(*e), + Value::Number(n) => Ok(match n.trunc() as i64 { + 1 | 101 => Agg::Average, + 2 | 102 => Agg::Count, + 3 | 103 => Agg::CountA, + 4 | 104 => Agg::Max, + 5 | 105 => Agg::Min, + 6 | 106 => Agg::Product, + 7 | 107 => Agg::StdevS, + 8 | 108 => Agg::StdevP, + 9 | 109 => Agg::Sum, + 10 | 110 => Agg::VarS, + 11 | 111 => Agg::VarP, + 12 => Agg::Median, + _ => return Err(CellError::Value), + }), + Value::Text(t) => { + let u = t.trim().to_ascii_uppercase(); + let u = u.strip_prefix("_XLFN.").unwrap_or(&u).to_string(); + Ok(match u.as_str() { + "SUM" => Agg::Sum, + "AVERAGE" => Agg::Average, + "COUNT" => Agg::Count, + "COUNTA" => Agg::CountA, + "MAX" => Agg::Max, + "MIN" => Agg::Min, + "PRODUCT" => Agg::Product, + "MEDIAN" => Agg::Median, + "STDEV.S" | "STDEV" => Agg::StdevS, + "STDEV.P" | "STDEVP" => Agg::StdevP, + "VAR.S" | "VAR" => Agg::VarS, + "VAR.P" | "VARP" => Agg::VarP, + _ => return Err(CellError::Value), + }) + } + Value::Array(a) => parse_agg(a.data.first().unwrap_or(&Value::Empty)), + _ => Err(CellError::Value), + } +} + +fn aggregate(f: Agg, vals: &[&Value]) -> Value { + if f == Agg::CountA { + return Value::Number(vals.iter().filter(|v| !matches!(v, Value::Empty)).count() as f64); + } + let mut xs = Vec::new(); + for v in vals { + match v { + Value::Number(n) => xs.push(*n), + Value::Error(e) if f != Agg::Count => return Value::Error(*e), + _ => {} + } + } + let n = xs.len() as f64; + let sum: f64 = xs.iter().sum(); + let var = |ddof: f64| -> Value { + if n - ddof <= 0.0 { + return Value::Error(CellError::Div0); + } + let m = sum / n; + Value::number(xs.iter().map(|x| (x - m) * (x - m)).sum::() / (n - ddof)) + }; + match f { + Agg::Sum => Value::number(sum), + Agg::Count => Value::Number(n), + Agg::Average => { + if xs.is_empty() { + Value::Error(CellError::Div0) + } else { + Value::number(sum / n) + } + } + Agg::Max => Value::number(if xs.is_empty() { 0.0 } else { xs.iter().copied().fold(f64::NEG_INFINITY, f64::max) }), + Agg::Min => Value::number(if xs.is_empty() { 0.0 } else { xs.iter().copied().fold(f64::INFINITY, f64::min) }), + Agg::Product => Value::number(if xs.is_empty() { 0.0 } else { xs.iter().product() }), + Agg::Median => { + if xs.is_empty() { + return Value::Error(CellError::Num); + } + xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + let m = xs.len() / 2; + let v = if xs.len() % 2 == 1 { + xs.get(m).copied().unwrap_or(0.0) + } else { + (xs.get(m - 1).copied().unwrap_or(0.0) + xs.get(m).copied().unwrap_or(0.0)) / 2.0 + }; + Value::number(v) + } + Agg::VarS => var(1.0), + Agg::VarP => var(0.0), + Agg::StdevS => match var(1.0) { + Value::Number(x) => Value::number(x.sqrt()), + o => o, + }, + Agg::StdevP => match var(0.0) { + Value::Number(x) => Value::number(x.sqrt()), + o => o, + }, + Agg::CountA => Value::Number(0.0), + } +} + +/// Shared input preparation for GROUPBY/PIVOTBY. +struct Prepared { + /// Header text for each row field (or generated) when headers are shown. + field_headers: Vec, + value_headers: Vec, + show_headers: bool, + /// Indices of data rows that pass the filter. + rows: Vec, +} + +/// field_headers: 0 none, 1 present but hidden, 2 generate, 3 present and shown; missing = +/// automatic (headers when the first value row is all text and the next is not). +fn prepare(fields: &[&Array], values: &Array, fh: Option, filter: Option<&Array>) -> R { + let n = values.rows; + for f in fields { + if f.rows != n { + return Err(CellError::Value); + } + } + let first_text = (0..values.cols).all(|c| matches!(values.get(0, c), Some(Value::Text(_)))); + let second_not = n > 1 && (0..values.cols).any(|c| !matches!(values.get(1, c), Some(Value::Text(_)))); + let mode = match fh { + Some(m @ 0..=3) => m, + Some(_) => return Err(CellError::Value), + None => { + if first_text && second_not { + 3 + } else { + 0 + } + } + }; + let has_hdr = mode == 1 || mode == 3; + let show = mode >= 2; + let mut field_headers = Vec::new(); + let mut k = 0; + for f in fields { + for c in 0..f.cols { + k += 1; + field_headers.push(if has_hdr { f.get(0, c).cloned().unwrap_or(Value::Empty) } else { Value::from(format!("Field {k}")) }); + } + } + let value_headers = (0..values.cols) + .map(|c| if has_hdr { values.get(0, c).cloned().unwrap_or(Value::Empty) } else { Value::from(format!("Value {}", c + 1)) }) + .collect(); + let start = usize::from(has_hdr); + let mut rows = Vec::new(); + for r in start..n { + if let Some(fa) = filter { + let v = fa.data.get(r).cloned().unwrap_or(Value::Empty); + if !truthy(&v)? { + continue; + } + } + rows.push(r); + } + Ok(Prepared { field_headers, value_headers, show_headers: show, rows }) +} + +fn key_row(a: &Array, r: usize) -> Vec { + (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect() +} + +/// Distinct keys in first-seen order, then sorted (by `sort` = ±column index within the key; 0 +/// keeps all keys ascending). +fn distinct_keys(keys: &[Vec], sort: i64) -> Vec> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for k in keys { + if seen.insert(row_key(k)) { + out.push(k.clone()); + } + } + let width = out.first().map_or(0, Vec::len); + let mut orders = vec![1i64; width]; + if sort != 0 { + let i = sort.unsigned_abs() as usize; + if i >= 1 && i <= width { + if let Some(o) = orders.get_mut(i - 1) { + *o = sort.signum(); + } + // Sort key column first. + out.sort_by(|a, b| { + let ka = vec![a.get(i - 1).cloned().unwrap_or(Value::Empty)]; + let kb = vec![b.get(i - 1).cloned().unwrap_or(Value::Empty)]; + cmp_keys(&ka, &kb, &[sort.signum()]).then_with(|| cmp_keys(a, b, &vec![1; width])) + }); + return out; + } + } + out.sort_by(|a, b| cmp_keys(a, b, &orders)); + out +} + +fn opt_arr(args: &[Arg], i: usize) -> R> { + if has(args, i) { Ok(Some(arr_arg(args, i)?)) } else { Ok(None) } +} + +fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R { + let rf = arr_arg(args, 0)?; + let vals = arr_arg(args, 1)?; + let f = parse_agg(&arg(args, 2)?.value)?; + let fh = opt_count(args, 3)?; + let total = opt_count(args, 4)?.unwrap_or(1); + let sort = opt_count(args, 5)?.unwrap_or(1); + let filter = opt_arr(args, 6)?; + let p = prepare(&[&rf], &vals, fh, filter.as_ref())?; + let keys: Vec> = p.rows.iter().map(|&r| key_row(&rf, r)).collect(); + let mut groups: HashMap> = HashMap::new(); + for (k, &r) in keys.iter().zip(p.rows.iter()) { + groups.entry(row_key(k)).or_default().push(r); + } + let sort_in_keys = if sort.unsigned_abs() as usize <= rf.cols { sort } else { 0 }; + let mut ordered = distinct_keys(&keys, sort_in_keys); + let agg_row = |rows: &[usize]| -> Vec { + (0..vals.cols) + .map(|c| { + let vs: Vec<&Value> = rows.iter().filter_map(|&r| vals.get(r, c)).collect(); + aggregate(f, &vs) + }) + .collect() + }; + let mut lines: Vec> = ordered + .drain(..) + .map(|k| { + let rows = groups.get(&row_key(&k)).cloned().unwrap_or_default(); + let mut line = k; + line.extend(agg_row(&rows)); + line + }) + .collect(); + // Sorting by a value column. + if sort.unsigned_abs() as usize > rf.cols { + let ci = sort.unsigned_abs() as usize - 1; + lines + .sort_by(|a, b| cmp_keys(&[a.get(ci).cloned().unwrap_or(Value::Empty)], &[b.get(ci).cloned().unwrap_or(Value::Empty)], &[sort.signum()])); + } + let mut out = Vec::new(); + if p.show_headers { + let mut h = p.field_headers.clone(); + h.extend(p.value_headers.iter().cloned()); + out.push(h); + } + let mut total_line = vec![Value::from("Total")]; + total_line.resize(rf.cols, Value::Empty); + total_line.extend(agg_row(&p.rows)); + if total < 0 { + out.push(total_line); + out.extend(lines); + } else { + out.extend(lines); + if total > 0 { + out.push(total_line); + } + } + from_rows(out, &Value::Empty) +} + +fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R { + let rf = arr_arg(args, 0)?; + let cf = arr_arg(args, 1)?; + let vals = arr_arg(args, 2)?; + let f = parse_agg(&arg(args, 3)?.value)?; + let fh = opt_count(args, 4)?; + let row_total = opt_count(args, 5)?.unwrap_or(1); + let row_sort = opt_count(args, 6)?.unwrap_or(1); + let col_total = opt_count(args, 7)?.unwrap_or(1); + let col_sort = opt_count(args, 8)?.unwrap_or(1); + let filter = opt_arr(args, 9)?; + let p = prepare(&[&rf, &cf], &vals, fh, filter.as_ref())?; + let rkeys: Vec> = p.rows.iter().map(|&r| key_row(&rf, r)).collect(); + let ckeys: Vec> = p.rows.iter().map(|&r| key_row(&cf, r)).collect(); + let rk = distinct_keys(&rkeys, if row_sort.unsigned_abs() as usize <= rf.cols { row_sort } else { 0 }); + let ck = distinct_keys(&ckeys, if col_sort.unsigned_abs() as usize <= cf.cols { col_sort } else { 0 }); + if rk.len().saturating_add(2).saturating_mul((ck.len() + 1).saturating_mul(vals.cols).saturating_add(rf.cols)) > MAX_CELLS { + return Err(CellError::Num); + } + let mut cell: HashMap<(String, String), Vec> = HashMap::new(); + for ((r, c), &i) in rkeys.iter().zip(ckeys.iter()).zip(p.rows.iter()) { + cell.entry((row_key(r), row_key(c))).or_default().push(i); + } + let agg = |rows: &[usize], c: usize| -> Value { + if rows.is_empty() { + return Value::Empty; + } + let vs: Vec<&Value> = rows.iter().filter_map(|&r| vals.get(r, c)).collect(); + aggregate(f, &vs) + }; + let rows_for = |r: Option<&Vec>, c: Option<&Vec>| -> Vec { + let rkey = r.map(|k| row_key(k)); + let ckey = c.map(|k| row_key(k)); + p.rows + .iter() + .zip(rkeys.iter().zip(ckeys.iter())) + .filter(|(_, (a, b))| rkey.as_ref().is_none_or(|k| *k == row_key(a)) && ckey.as_ref().is_none_or(|k| *k == row_key(b))) + .map(|(&i, _)| i) + .collect() + }; + let mut cols: Vec>> = ck.iter().map(Some).collect(); + if col_total > 0 { + cols.push(None); + } else if col_total < 0 { + cols.insert(0, None); + } + let lead = rf.cols; + let mut out: Vec> = Vec::new(); + // Column header rows: one per column field, plus value names when several value columns. + for level in 0..cf.cols { + let mut line = vec![Value::Empty; lead]; + for c in &cols { + for _ in 0..vals.cols { + line.push(match c { + Some(k) => k.get(level).cloned().unwrap_or(Value::Empty), + None if level == 0 => Value::from("Total"), + None => Value::Empty, + }); + } + } + out.push(line); + } + if vals.cols > 1 || p.show_headers { + let mut line: Vec = if p.show_headers { p.field_headers.iter().take(lead).cloned().collect() } else { vec![Value::Empty; lead] }; + line.resize(lead, Value::Empty); + for _ in &cols { + line.extend(p.value_headers.iter().cloned()); + } + out.push(line); + } + let body_line = |r: Option<&Vec>| -> Vec { + let mut line: Vec = match r { + Some(k) => k.clone(), + None => { + let mut l = vec![Value::from("Total")]; + l.resize(lead, Value::Empty); + l + } + }; + for c in &cols { + let rows = match (r, c) { + (Some(rk), Some(ck)) => cell.get(&(row_key(rk), row_key(ck))).cloned().unwrap_or_default(), + _ => rows_for(r, *c), + }; + for vc in 0..vals.cols { + line.push(agg(&rows, vc)); + } + } + line + }; + if row_total < 0 { + out.push(body_line(None)); + } + for k in &rk { + out.push(body_line(Some(k))); + } + if row_total > 0 { + out.push(body_line(None)); + } + from_rows(out, &Value::Empty) +} + +pub(crate) fn specs() -> Vec { + vec![ + f!( + "FILTER", + 2, + 3, + Lookup, + A, + "FILTER(array, include, [if_empty])", + "Keeps the rows or columns of an array whose include flag is TRUE.", + filter + ), + f!( + "SORT", + 1, + 4, + Lookup, + A, + "SORT(array, [sort_index], [sort_order], [by_col])", + "Sorts the rows (or columns) of an array by one or more of its columns.", + sort + ), + f!( + "SORTBY", + 2, + VAR, + Lookup, + A, + "SORTBY(array, by_array1, [sort_order1], ...)", + "Sorts an array by the values of other arrays of matching size.", + sortby + ), + f!("UNIQUE", 1, 3, Lookup, A, "UNIQUE(array, [by_col], [exactly_once])", "Returns the distinct rows or columns of an array.", unique), + f!("SEQUENCE", 1, 4, MathTrig, A, "SEQUENCE(rows, [columns], [start], [step])", "Builds an array of evenly stepped numbers.", sequence), + f!(volatile; + "RANDARRAY", + 0, + 5, + MathTrig, + A, + "RANDARRAY([rows], [columns], [min], [max], [whole_number])", + "Builds an array of random numbers in a range.", + randarray + ), + f!("VSTACK", 1, VAR, Lookup, A, "VSTACK(array1, [array2], ...)", "Stacks arrays on top of each other.", vstack), + f!("HSTACK", 1, VAR, Lookup, A, "HSTACK(array1, [array2], ...)", "Places arrays side by side.", hstack), + f!( + "TOROW", + 1, + 3, + Lookup, + A, + "TOROW(array, [ignore], [scan_by_column])", + "Flattens an array into a single row, optionally skipping blanks or errors.", + torow + ), + f!( + "TOCOL", + 1, + 3, + Lookup, + A, + "TOCOL(array, [ignore], [scan_by_column])", + "Flattens an array into a single column, optionally skipping blanks or errors.", + tocol + ), + f!( + "WRAPROWS", + 2, + 3, + Lookup, + A, + "WRAPROWS(vector, wrap_count, [pad_with])", + "Breaks a row or column into rows of the given length.", + wraprows + ), + f!( + "WRAPCOLS", + 2, + 3, + Lookup, + A, + "WRAPCOLS(vector, wrap_count, [pad_with])", + "Breaks a row or column into columns of the given length.", + wrapcols + ), + f!("TAKE", 2, 3, Lookup, A, "TAKE(array, rows, [columns])", "Keeps rows or columns from the start (or end) of an array.", take_fn), + f!("DROP", 2, 3, Lookup, A, "DROP(array, rows, [columns])", "Removes rows or columns from the start (or end) of an array.", drop_fn), + f!( + "CHOOSEROWS", + 2, + VAR, + Lookup, + A, + "CHOOSEROWS(array, row_num1, [row_num2], ...)", + "Picks rows of an array by position (negative counts from the end).", + chooserows + ), + f!( + "CHOOSECOLS", + 2, + VAR, + Lookup, + A, + "CHOOSECOLS(array, col_num1, [col_num2], ...)", + "Picks columns of an array by position (negative counts from the end).", + choosecols + ), + f!( + "EXPAND", + 2, + 4, + Lookup, + A, + "EXPAND(array, rows, [columns], [pad_with])", + "Grows an array to the given size, filling new cells with a pad value.", + expand + ), + f!( + "GROUPBY", + 3, + 8, + Lookup, + A, + "GROUPBY(row_fields, values, function, [field_headers], [total_depth], [sort_order], [filter_array], [field_relationship])", + "Groups rows by key fields and aggregates the values of each group.", + groupby + ), + f!( + "PIVOTBY", + 4, + 11, + Lookup, + A, + "PIVOTBY(row_fields, col_fields, values, function, [field_headers], [row_total_depth], [row_sort_order], [col_total_depth], [col_sort_order], [filter_array], [relative_to])", + "Builds a summary table grouping values by row and column fields.", + pivotby + ), + ] +} + +#[cfg(test)] +mod tests { + use sheetcraft_core::{CellError, Value}; + + use crate::util::testutil::*; + + fn grid() -> Value { + arr(vec![vec![tv("b"), nv(2.0), nv(20.0)], vec![tv("a"), nv(3.0), nv(10.0)], vec![tv("c"), nv(1.0), nv(30.0)]]) + } + + #[test] + fn filter() { + let inc = arr(vec![vec![Value::Bool(true)], vec![Value::Bool(false)], vec![Value::Bool(true)]]); + let r = ev("FILTER", vec![rf(grid()), av(inc)]); + assert_eq!(rows_of(&r), vec![vec![tv("b"), nv(2.0), nv(20.0)], vec![tv("c"), nv(1.0), nv(30.0)]]); + let r = ev("FILTER", vec![rf(grid()), av(col(&[0.0, 1.0, 0.0]))]); + assert_eq!(rows_of(&r), vec![vec![tv("a"), nv(3.0), nv(10.0)]]); + let r = ev("FILTER", vec![rf(grid()), av(row(&[1.0, 0.0, 1.0]))]); + assert_eq!(rows_of(&r).first().unwrap(), &vec![tv("b"), nv(20.0)]); + is_err(ev("FILTER", vec![rf(grid()), av(col(&[0.0, 0.0, 0.0]))]), CellError::Calc); + is_text(ev("FILTER", vec![rf(grid()), av(col(&[0.0, 0.0, 0.0])), t("none")]), "none"); + is_err(ev("FILTER", vec![rf(grid()), av(col(&[1.0, 0.0]))]), CellError::Value); + let errinc = arr(vec![vec![nv(1.0)], vec![Value::Error(CellError::Div0)], vec![nv(1.0)]]); + is_err(ev("FILTER", vec![rf(grid()), av(errinc)]), CellError::Div0); + } + + #[test] + fn sort() { + let r = ev("SORT", vec![rf(grid())]); + assert_eq!(rows_of(&r).iter().map(|l| l[0].clone()).collect::>(), vec![tv("a"), tv("b"), tv("c")]); + let r = ev("SORT", vec![rf(grid()), n(2.0), n(-1.0)]); + assert_eq!(rows_of(&r).iter().map(|l| l[1].clone()).collect::>(), vec![nv(3.0), nv(2.0), nv(1.0)]); + let r = ev("SORT", vec![av(row(&[3.0, 1.0, 2.0])), n(1.0), n(1.0), b(true)]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0), nv(2.0), nv(3.0)]]); + is_err(ev("SORT", vec![rf(grid()), n(4.0)]), CellError::Value); + is_err(ev("SORT", vec![rf(grid()), n(1.0), n(2.0)]), CellError::Value); + let mixed = arr(vec![vec![tv("x")], vec![nv(5.0)], vec![Value::Bool(true)], vec![nv(-1.0)]]); + assert_eq!(rows_of(&ev("SORT", vec![av(mixed)])), vec![vec![nv(-1.0)], vec![nv(5.0)], vec![tv("x")], vec![Value::Bool(true)]]); + // Multiple keys. + let m = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(1.0), nv(1.0)], vec![nv(0.0), nv(5.0)]]); + let r = ev("SORT", vec![av(m), av(row(&[1.0, 2.0])), av(row(&[1.0, -1.0]))]); + assert_eq!(rows_of(&r), vec![vec![nv(0.0), nv(5.0)], vec![nv(1.0), nv(2.0)], vec![nv(1.0), nv(1.0)]]); + } + + #[test] + fn sortby() { + let r = ev("SORTBY", vec![av(col(&[1.0, 2.0, 3.0])), av(col(&[30.0, 10.0, 20.0]))]); + assert_eq!(rows_of(&r), vec![vec![nv(2.0)], vec![nv(3.0)], vec![nv(1.0)]]); + let r = ev("SORTBY", vec![av(col(&[1.0, 2.0, 3.0])), av(col(&[30.0, 10.0, 20.0])), n(-1.0)]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0)], vec![nv(3.0)], vec![nv(2.0)]]); + let r = ev("SORTBY", vec![av(row(&[1.0, 2.0])), av(row(&[2.0, 1.0]))]); + assert_eq!(rows_of(&r), vec![vec![nv(2.0), nv(1.0)]]); + is_err(ev("SORTBY", vec![av(col(&[1.0, 2.0, 3.0])), av(col(&[1.0, 2.0]))]), CellError::Value); + is_err(ev("SORTBY", vec![av(col(&[1.0, 2.0])), av(col(&[1.0, 2.0])), n(0.0)]), CellError::Value); + } + + #[test] + fn unique() { + let v = arr(vec![vec![tv("a")], vec![tv("B")], vec![tv("A")], vec![tv("c")], vec![tv("b")]]); + assert_eq!(rows_of(&ev("UNIQUE", vec![av(v.clone())])), vec![vec![tv("a")], vec![tv("B")], vec![tv("c")]]); + assert_eq!(rows_of(&ev("UNIQUE", vec![av(v.clone()), b(false), b(true)])), vec![vec![tv("c")]]); + assert_eq!(rows_of(&ev("UNIQUE", vec![av(row(&[1.0, 2.0, 1.0])), b(true)])), vec![vec![nv(1.0), nv(2.0)]]); + is_err(ev("UNIQUE", vec![av(col(&[1.0, 1.0])), b(false), b(true)]), CellError::Calc); + let m = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(1.0), nv(2.0)], vec![nv(1.0), nv(3.0)]]); + assert_eq!(rows_of(&ev("UNIQUE", vec![av(m)])).len(), 2); + } + + #[test] + fn sequence_and_rand() { + assert_eq!(rows_of(&ev("SEQUENCE", vec![n(3.0)])), vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(3.0)]]); + assert_eq!(rows_of(&ev("SEQUENCE", vec![n(2.0), n(2.0), n(0.0), n(5.0)])), vec![vec![nv(0.0), nv(5.0)], vec![nv(10.0), nv(15.0)]]); + assert_eq!(rows_of(&ev("SEQUENCE", vec![empty(), n(3.0)])), vec![vec![nv(1.0), nv(2.0), nv(3.0)]]); + is_err(ev("SEQUENCE", vec![n(0.0)]), CellError::Calc); + is_err(ev("SEQUENCE", vec![n(-1.0)]), CellError::Value); + is_err(ev("SEQUENCE", vec![n(1e6), n(1e6)]), CellError::Num); + is_err(ev("SEQUENCE", vec![t("x")]), CellError::Value); + let r = ev("RANDARRAY", vec![n(3.0), n(4.0), n(1.0), n(6.0), b(true)]); + let rows = rows_of(&r); + assert_eq!((rows.len(), rows[0].len()), (3, 4)); + for v in rows.iter().flatten() { + let x = v.as_f64().unwrap(); + assert!((1.0..=6.0).contains(&x) && x.fract() == 0.0); + } + let x = ev("RANDARRAY", vec![]); + assert!(x.as_f64().is_some_and(|x| (0.0..1.0).contains(&x)) || matches!(x, Value::Array(_))); + is_err(ev("RANDARRAY", vec![n(1.0), n(1.0), n(5.0), n(1.0)]), CellError::Value); + assert!(crate::lookup("RANDARRAY").unwrap().volatile); + } + + #[test] + fn stacking() { + let r = ev("VSTACK", vec![av(row(&[1.0, 2.0])), av(row(&[3.0]))]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), Value::Error(CellError::NA)]]); + let r = ev("HSTACK", vec![av(col(&[1.0, 2.0])), n(3.0)]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0), nv(3.0)], vec![nv(2.0), Value::Error(CellError::NA)]]); + let m = arr(vec![vec![nv(1.0), Value::Empty], vec![Value::Error(CellError::Div0), nv(4.0)]]); + assert_eq!(rows_of(&ev("TOROW", vec![av(m.clone())])).first().unwrap().len(), 4); + assert_eq!(rows_of(&ev("TOROW", vec![av(m.clone()), n(1.0)])), vec![vec![nv(1.0), Value::Error(CellError::Div0), nv(4.0)]]); + assert_eq!(rows_of(&ev("TOCOL", vec![av(m.clone()), n(3.0)])), vec![vec![nv(1.0)], vec![nv(4.0)]]); + assert_eq!(rows_of(&ev("TOCOL", vec![av(m.clone()), n(2.0), b(true)])), vec![vec![nv(1.0)], vec![Value::Empty], vec![nv(4.0)]]); + is_err(ev("TOCOL", vec![av(m), n(4.0)]), CellError::Value); + is_err(ev("TOROW", vec![av(arr(vec![vec![Value::Empty]])), n(1.0)]), CellError::Calc); + } + + #[test] + fn wrapping() { + let r = ev("WRAPROWS", vec![av(row(&[1.0, 2.0, 3.0, 4.0, 5.0])), n(2.0)]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)], vec![nv(5.0), Value::Error(CellError::NA)]]); + let r = ev("WRAPCOLS", vec![av(row(&[1.0, 2.0, 3.0])), n(2.0), n(0.0)]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0), nv(3.0)], vec![nv(2.0), nv(0.0)]]); + is_err(ev("WRAPROWS", vec![rf(grid()), n(2.0)]), CellError::Value); + is_err(ev("WRAPROWS", vec![av(row(&[1.0])), n(0.0)]), CellError::Num); + } + + #[test] + fn take_drop_choose_expand() { + let r = ev("TAKE", vec![rf(grid()), n(2.0)]); + assert_eq!(rows_of(&r).len(), 2); + let r = ev("TAKE", vec![rf(grid()), n(-1.0), n(1.0)]); + assert_eq!(rows_of(&r), vec![vec![tv("c")]]); + let r = ev("TAKE", vec![rf(grid()), empty(), n(-1.0)]); + assert_eq!(rows_of(&r), vec![vec![nv(20.0)], vec![nv(10.0)], vec![nv(30.0)]]); + assert_eq!(rows_of(&ev("TAKE", vec![rf(grid()), n(10.0)])).len(), 3); + is_err(ev("TAKE", vec![rf(grid()), n(0.0)]), CellError::Calc); + let r = ev("DROP", vec![rf(grid()), n(1.0), n(2.0)]); + assert_eq!(rows_of(&r), vec![vec![nv(10.0)], vec![nv(30.0)]]); + let r = ev("DROP", vec![rf(grid()), n(-2.0)]); + assert_eq!(rows_of(&r), vec![vec![tv("b"), nv(2.0), nv(20.0)]]); + is_err(ev("DROP", vec![rf(grid()), n(3.0)]), CellError::Calc); + let r = ev("CHOOSEROWS", vec![rf(grid()), n(3.0), n(-3.0)]); + assert_eq!(rows_of(&r), vec![vec![tv("c"), nv(1.0), nv(30.0)], vec![tv("b"), nv(2.0), nv(20.0)]]); + let r = ev("CHOOSECOLS", vec![rf(grid()), av(row(&[1.0, 3.0]))]); + assert_eq!(rows_of(&r).first().unwrap(), &vec![tv("b"), nv(20.0)]); + is_err(ev("CHOOSEROWS", vec![rf(grid()), n(4.0)]), CellError::Value); + is_err(ev("CHOOSECOLS", vec![rf(grid()), n(0.0)]), CellError::Value); + let r = ev("EXPAND", vec![av(row(&[1.0, 2.0])), n(2.0), empty(), n(0.0)]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0), nv(2.0)], vec![nv(0.0), nv(0.0)]]); + let r = ev("EXPAND", vec![av(row(&[1.0])), n(1.0), n(2.0)]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0), Value::Error(CellError::NA)]]); + is_err(ev("EXPAND", vec![rf(grid()), n(2.0)]), CellError::Value); + is_err(ev("EXPAND", vec![av(row(&[1.0])), n(1e7), n(1e4)]), CellError::Num); + } + + fn sales() -> (Value, Value, Value) { + let region = arr(vec![vec![tv("Region")], vec![tv("East")], vec![tv("West")], vec![tv("East")], vec![tv("West")], vec![tv("North")]]); + let item = arr(vec![vec![tv("Item")], vec![tv("A")], vec![tv("A")], vec![tv("B")], vec![tv("B")], vec![tv("A")]]); + let amt = arr(vec![vec![tv("Amount")], vec![nv(10.0)], vec![nv(20.0)], vec![nv(5.0)], vec![nv(7.0)], vec![nv(1.0)]]); + (region, item, amt) + } + + #[test] + fn groupby() { + let (region, _, amt) = sales(); + let r = ev("GROUPBY", vec![rf(region.clone()), rf(amt.clone()), t("SUM")]); + assert_eq!( + rows_of(&r), + vec![ + vec![tv("Region"), tv("Amount")], + vec![tv("East"), nv(15.0)], + vec![tv("North"), nv(1.0)], + vec![tv("West"), nv(27.0)], + vec![tv("Total"), nv(43.0)], + ] + ); + let r = ev("GROUPBY", vec![rf(region.clone()), rf(amt.clone()), n(2.0), n(1.0), n(0.0), n(-1.0)]); + assert_eq!(rows_of(&r), vec![vec![tv("West"), nv(2.0)], vec![tv("North"), nv(1.0)], vec![tv("East"), nv(2.0)]]); + let r = ev("GROUPBY", vec![rf(region.clone()), rf(amt.clone()), t("max"), n(1.0), n(0.0), n(-2.0)]); + assert_eq!(rows_of(&r).first().unwrap(), &vec![tv("West"), nv(20.0)]); + let filt = arr(vec![ + vec![Value::Empty], + vec![Value::Bool(true)], + vec![Value::Bool(false)], + vec![Value::Bool(true)], + vec![Value::Bool(true)], + vec![Value::Bool(true)], + ]); + let r = ev("GROUPBY", vec![rf(region.clone()), rf(amt.clone()), t("AVERAGE"), n(1.0), n(0.0), n(1.0), av(filt)]); + assert_eq!(rows_of(&r), vec![vec![tv("East"), nv(7.5)], vec![tv("North"), nv(1.0)], vec![tv("West"), nv(7.0)]]); + is_err(ev("GROUPBY", vec![rf(region.clone()), rf(amt.clone()), t("BOGUS")]), CellError::Value); + is_err(ev("GROUPBY", vec![rf(region), av(col(&[1.0])), t("SUM")]), CellError::Value); + } + + #[test] + fn pivotby() { + let (region, item, amt) = sales(); + let r = ev("PIVOTBY", vec![rf(region), rf(item), rf(amt), t("SUM"), n(1.0)]); + assert_eq!( + rows_of(&r), + vec![ + vec![Value::Empty, tv("A"), tv("B"), tv("Total")], + vec![tv("East"), nv(10.0), nv(5.0), nv(15.0)], + vec![tv("North"), nv(1.0), Value::Empty, nv(1.0)], + vec![tv("West"), nv(20.0), nv(7.0), nv(27.0)], + vec![tv("Total"), nv(31.0), nv(12.0), nv(43.0)], + ] + ); + } +} diff --git a/crates/functions/src/engineering.rs b/crates/functions/src/engineering.rs new file mode 100644 index 00000000..5fc8ea27 --- /dev/null +++ b/crates/functions/src/engineering.rs @@ -0,0 +1,1341 @@ +//! Engineering functions: base conversion, bit operations, unit conversion, error function, +//! complex numbers and Bessel functions. + +use std::f64::consts::PI; + +use sheetcraft_core::{CellError, Value, number_to_text}; + +use crate::special::{erf, erfc}; +use crate::util::{A, R, S, flatten, has, num, num_val, opt_num, scalar, text, text_val}; +use crate::{Arg, Ctx, FnSpec, VAR}; + +// --------------------------------------------------------------------------------------------- +// Base conversion + +/// Source text of a base-conversion argument (numbers are written out as integers). +fn base_source(a: &[Arg]) -> R { + let v = a.first().map(scalar).unwrap_or(Value::Empty); + match v { + Value::Error(e) => Err(e), + Value::Bool(_) => Err(CellError::Value), + Value::Number(n) => { + if n < 0.0 || n != n.trunc() { + return Err(CellError::Num); + } + Ok(number_to_text(n)) + } + Value::Empty => Ok(String::new()), + v => Ok(v.to_text()?.trim().to_string()), + } +} + +/// Parses a 10-digit two's-complement number in `radix`. +fn parse_base(s: &str, radix: u32) -> R { + if s.len() > 10 { + return Err(CellError::Num); + } + if s.is_empty() { + return Ok(0); + } + let v = i64::from_str_radix(s, radix).map_err(|_| CellError::Num)?; + if s.starts_with(['+', '-']) { + return Err(CellError::Num); + } + let bits = match radix { + 2 => 10, + 8 => 30, + _ => 40, + }; + // A full 10-digit number with the top bit set is negative. + if s.len() == 10 && v >= 1i64 << (bits - 1) { Ok(v - (1i64 << bits)) } else { Ok(v) } +} + +/// Writes `v` in `radix`, using 10-digit two's complement for negatives and padding to `places`. +fn format_base(v: i64, radix: u32, places: Option) -> R { + let bits: u32 = match radix { + 2 => 10, + 8 => 30, + _ => 40, + }; + let min = -(1i64 << (bits - 1)); + let max = (1i64 << (bits - 1)) - 1; + if v < min || v > max { + return Err(CellError::Num); + } + if v < 0 { + let u = v + (1i64 << bits); + return text_val(to_radix(u, radix)); + } + let s = to_radix(v, radix); + match places { + None => text_val(s), + Some(p) => { + let p = p.trunc(); + if !(1.0..=10.0).contains(&p) { + return Err(CellError::Num); + } + let p = p as usize; + if s.len() > p { + return Err(CellError::Num); + } + text_val(format!("{}{}", "0".repeat(p - s.len()), s)) + } + } +} + +fn to_radix(mut v: i64, radix: u32) -> String { + if v == 0 { + return "0".into(); + } + let mut out = Vec::new(); + while v > 0 { + let d = (v % radix as i64) as u32; + out.push(char::from_digit(d, radix).unwrap_or('0').to_ascii_uppercase()); + v /= radix as i64; + } + out.iter().rev().collect() +} + +fn places_arg(a: &[Arg]) -> R> { + if has(a, 1) { Ok(Some(num(a, 1)?)) } else { Ok(None) } +} + +fn convert_base(a: &[Arg], from: u32, to: u32) -> R { + let s = base_source(a)?; + let v = parse_base(&s, from)?; + let places = places_arg(a)?; + if to == 10 { + return Ok(Value::Number(v as f64)); + } + format_base(v, to, places) +} + +fn dec_to(a: &[Arg], to: u32) -> R { + let v = a.first().map(scalar).unwrap_or(Value::Empty); + if matches!(v, Value::Bool(_)) { + return Err(CellError::Value); + } + let n = v.to_number()?.trunc(); + if n.abs() > 1e15 { + return Err(CellError::Num); + } + format_base(n as i64, to, places_arg(a)?) +} + +macro_rules! base_fn { + ($name:ident, $from:expr, $to:expr) => { + fn $name(a: &[Arg], _c: &mut dyn Ctx) -> R { + convert_base(a, $from, $to) + } + }; +} +base_fn!(bin2dec, 2, 10); +base_fn!(bin2hex, 2, 16); +base_fn!(bin2oct, 2, 8); +base_fn!(hex2bin, 16, 2); +base_fn!(hex2dec, 16, 10); +base_fn!(hex2oct, 16, 8); +base_fn!(oct2bin, 8, 2); +base_fn!(oct2dec, 8, 10); +base_fn!(oct2hex, 8, 16); + +fn dec2bin(a: &[Arg], _c: &mut dyn Ctx) -> R { + dec_to(a, 2) +} +fn dec2oct(a: &[Arg], _c: &mut dyn Ctx) -> R { + dec_to(a, 8) +} +fn dec2hex(a: &[Arg], _c: &mut dyn Ctx) -> R { + dec_to(a, 16) +} + +// --------------------------------------------------------------------------------------------- +// Bits + +const BIT_LIMIT: f64 = 281_474_976_710_656.0; // 2^48 + +fn bit_operand(a: &[Arg], i: usize) -> R { + let n = num(a, i)?; + if !(0.0..BIT_LIMIT).contains(&n) || n != n.trunc() { + return Err(CellError::Num); + } + Ok(n as u64) +} + +fn bitand(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Number((bit_operand(a, 0)? & bit_operand(a, 1)?) as f64)) +} +fn bitor(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Number((bit_operand(a, 0)? | bit_operand(a, 1)?) as f64)) +} +fn bitxor(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Number((bit_operand(a, 0)? ^ bit_operand(a, 1)?) as f64)) +} + +fn shift(a: &[Arg], left: bool) -> R { + let n = bit_operand(a, 0)?; + let s = num(a, 1)?.trunc(); + if s.abs() > 53.0 { + return Err(CellError::Num); + } + let s = if left { s as i64 } else { -(s as i64) }; + let r = if s >= 0 { + let r = (n as u128) << s; + if r >= BIT_LIMIT as u128 { + return Err(CellError::Num); + } + r as u64 + } else { + n >> (-s) + }; + Ok(Value::Number(r as f64)) +} +fn bitlshift(a: &[Arg], _c: &mut dyn Ctx) -> R { + shift(a, true) +} +fn bitrshift(a: &[Arg], _c: &mut dyn Ctx) -> R { + shift(a, false) +} + +// --------------------------------------------------------------------------------------------- +// CONVERT + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Dim { + Mass, + Length, + Time, + Pressure, + Force, + Energy, + Power, + Magnetism, + Temperature, + Volume, + Area, + Information, + Speed, +} + +/// How a unit accepts prefixes: none, SI (raised to `power` for areas/volumes) or SI + binary. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Pre { + No, + Si(i32), + Bin, +} + +struct Unit { + names: &'static [&'static str], + dim: Dim, + factor: f64, + pre: Pre, +} + +const fn u(names: &'static [&'static str], dim: Dim, factor: f64, pre: Pre) -> Unit { + Unit { names, dim, factor, pre } +} + +const IN: f64 = 0.0254; +const FT: f64 = 0.3048; +const YD: f64 = 0.9144; +const MI: f64 = 1609.344; +const NMI: f64 = 1852.0; +const LY: f64 = 9_460_730_472_580_800.0; +const PICA_PT: f64 = 0.0254 / 72.0; +const PICA: f64 = 0.0254 / 6.0; +const GAL: f64 = 0.003_785_411_784; + +const UNITS: &[Unit] = &[ + // Mass (gram) + u(&["g"], Dim::Mass, 1.0, Pre::Si(1)), + u(&["sg"], Dim::Mass, 14_593.902_937_206_4, Pre::No), + u(&["lbm"], Dim::Mass, 453.592_37, Pre::No), + u(&["u"], Dim::Mass, 1.660_539_066_6e-24, Pre::Si(1)), + u(&["ozm"], Dim::Mass, 28.349_523_125, Pre::No), + u(&["grain"], Dim::Mass, 0.064_798_91, Pre::No), + u(&["cwt", "shweight"], Dim::Mass, 45_359.237, Pre::No), + u(&["uk_cwt", "lcwt", "hweight"], Dim::Mass, 50_802.345_44, Pre::No), + u(&["stone"], Dim::Mass, 6_350.293_18, Pre::No), + u(&["ton"], Dim::Mass, 907_184.74, Pre::No), + u(&["uk_ton", "LTON", "brton"], Dim::Mass, 1_016_046.908_8, Pre::No), + // Length (metre) + u(&["m"], Dim::Length, 1.0, Pre::Si(1)), + u(&["mi"], Dim::Length, MI, Pre::No), + u(&["Nmi"], Dim::Length, NMI, Pre::No), + u(&["in"], Dim::Length, IN, Pre::No), + u(&["ft"], Dim::Length, FT, Pre::No), + u(&["yd"], Dim::Length, YD, Pre::No), + u(&["ang"], Dim::Length, 1e-10, Pre::Si(1)), + u(&["ell"], Dim::Length, 1.143, Pre::No), + u(&["ly"], Dim::Length, LY, Pre::Si(1)), + u(&["parsec", "pc"], Dim::Length, 30_856_775_814_913_673.0, Pre::Si(1)), + u(&["Picapt", "Pica"], Dim::Length, PICA_PT, Pre::No), + u(&["pica"], Dim::Length, PICA, Pre::No), + u(&["survey_mi"], Dim::Length, 1_609.347_218_694_437, Pre::No), + // Time (second) + u(&["yr"], Dim::Time, 31_557_600.0, Pre::No), + u(&["day", "d"], Dim::Time, 86_400.0, Pre::No), + u(&["hr"], Dim::Time, 3_600.0, Pre::No), + u(&["mn", "min"], Dim::Time, 60.0, Pre::No), + u(&["sec", "s"], Dim::Time, 1.0, Pre::Si(1)), + // Pressure (pascal) + u(&["Pa", "p"], Dim::Pressure, 1.0, Pre::Si(1)), + u(&["atm", "at"], Dim::Pressure, 101_325.0, Pre::Si(1)), + u(&["mmHg"], Dim::Pressure, 133.322_387_415, Pre::Si(1)), + u(&["psi"], Dim::Pressure, 6_894.757_293_168_36, Pre::No), + u(&["Torr"], Dim::Pressure, 101_325.0 / 760.0, Pre::No), + // Force (newton) + u(&["N"], Dim::Force, 1.0, Pre::Si(1)), + u(&["dyn", "dy"], Dim::Force, 1e-5, Pre::Si(1)), + u(&["lbf"], Dim::Force, 4.448_221_615_260_5, Pre::No), + u(&["pond"], Dim::Force, 0.009_806_65, Pre::Si(1)), + // Energy (joule) + u(&["J"], Dim::Energy, 1.0, Pre::Si(1)), + u(&["e"], Dim::Energy, 1e-7, Pre::Si(1)), + u(&["c"], Dim::Energy, 4.184, Pre::Si(1)), + u(&["cal"], Dim::Energy, 4.1868, Pre::Si(1)), + u(&["eV", "ev"], Dim::Energy, 1.602_176_634e-19, Pre::Si(1)), + u(&["HPh", "hh"], Dim::Energy, 2_684_519.537_696_17, Pre::No), + u(&["Wh", "wh"], Dim::Energy, 3_600.0, Pre::Si(1)), + u(&["flb"], Dim::Energy, 1.355_817_948_331_4, Pre::No), + u(&["BTU", "btu"], Dim::Energy, 1_055.055_852_62, Pre::No), + // Power (watt) + u(&["W", "w"], Dim::Power, 1.0, Pre::Si(1)), + u(&["HP", "h"], Dim::Power, 745.699_871_582_270_2, Pre::No), + u(&["PS"], Dim::Power, 735.498_75, Pre::No), + // Magnetism (tesla) + u(&["T"], Dim::Magnetism, 1.0, Pre::Si(1)), + u(&["ga"], Dim::Magnetism, 1e-4, Pre::Si(1)), + // Temperature (handled specially; factor unused) + u(&["C", "cel"], Dim::Temperature, 1.0, Pre::No), + u(&["F", "fah"], Dim::Temperature, 1.0, Pre::No), + u(&["K", "kel"], Dim::Temperature, 1.0, Pre::Si(1)), + u(&["Rank"], Dim::Temperature, 1.0, Pre::No), + u(&["Reau"], Dim::Temperature, 1.0, Pre::No), + // Volume (cubic metre) + u(&["l", "L", "lt"], Dim::Volume, 0.001, Pre::Si(1)), + u(&["tsp"], Dim::Volume, GAL / 768.0, Pre::No), + u(&["tspm"], Dim::Volume, 5e-6, Pre::No), + u(&["tbs"], Dim::Volume, GAL / 256.0, Pre::No), + u(&["oz"], Dim::Volume, GAL / 128.0, Pre::No), + u(&["cup"], Dim::Volume, GAL / 16.0, Pre::No), + u(&["pt", "us_pt"], Dim::Volume, GAL / 8.0, Pre::No), + u(&["uk_pt"], Dim::Volume, 0.000_568_261_25, Pre::No), + u(&["qt"], Dim::Volume, GAL / 4.0, Pre::No), + u(&["uk_qt"], Dim::Volume, 0.001_136_522_5, Pre::No), + u(&["gal"], Dim::Volume, GAL, Pre::No), + u(&["uk_gal"], Dim::Volume, 0.004_546_09, Pre::No), + u(&["m3"], Dim::Volume, 1.0, Pre::Si(3)), + u(&["mi3"], Dim::Volume, MI * MI * MI, Pre::No), + u(&["Nmi3"], Dim::Volume, NMI * NMI * NMI, Pre::No), + u(&["yd3"], Dim::Volume, YD * YD * YD, Pre::No), + u(&["ft3"], Dim::Volume, FT * FT * FT, Pre::No), + u(&["in3"], Dim::Volume, IN * IN * IN, Pre::No), + u(&["ang3"], Dim::Volume, 1e-30, Pre::Si(3)), + u(&["ly3"], Dim::Volume, LY * LY * LY, Pre::No), + u(&["Picapt3", "Pica3"], Dim::Volume, PICA_PT * PICA_PT * PICA_PT, Pre::No), + u(&["barrel"], Dim::Volume, 0.158_987_294_928, Pre::No), + u(&["bushel"], Dim::Volume, 0.035_239_070_166_88, Pre::No), + u(&["MTON"], Dim::Volume, 1.132_673_863_68, Pre::No), + u(&["GRT", "regton"], Dim::Volume, 2.831_684_659_2, Pre::No), + // Area (square metre) + u(&["m2"], Dim::Area, 1.0, Pre::Si(2)), + u(&["ha"], Dim::Area, 10_000.0, Pre::No), + u(&["ar"], Dim::Area, 100.0, Pre::Si(1)), + u(&["ft2"], Dim::Area, FT * FT, Pre::No), + u(&["in2"], Dim::Area, IN * IN, Pre::No), + u(&["yd2"], Dim::Area, YD * YD, Pre::No), + u(&["mi2"], Dim::Area, MI * MI, Pre::No), + u(&["Nmi2"], Dim::Area, NMI * NMI, Pre::No), + u(&["ang2"], Dim::Area, 1e-20, Pre::Si(2)), + u(&["ly2"], Dim::Area, LY * LY, Pre::No), + u(&["Picapt2", "Pica2"], Dim::Area, PICA_PT * PICA_PT, Pre::No), + u(&["uk_acre"], Dim::Area, 4_046.856_422_4, Pre::No), + u(&["us_acre"], Dim::Area, 4_046.872_609_874_252, Pre::No), + u(&["Morgen"], Dim::Area, 2_500.0, Pre::No), + // Information (bit) + u(&["bit"], Dim::Information, 1.0, Pre::Bin), + u(&["byte"], Dim::Information, 8.0, Pre::Bin), + // Speed (metres per second) + u(&["m/s", "m/sec"], Dim::Speed, 1.0, Pre::Si(1)), + u(&["m/h", "m/hr"], Dim::Speed, 1.0 / 3600.0, Pre::Si(1)), + u(&["mph"], Dim::Speed, MI / 3600.0, Pre::No), + u(&["kn"], Dim::Speed, NMI / 3600.0, Pre::No), + u(&["admkn"], Dim::Speed, 6080.0 * FT / 3600.0, Pre::No), +]; + +const SI_PREFIXES: &[(&str, f64)] = &[ + ("da", 1e1), + ("Y", 1e24), + ("Z", 1e21), + ("E", 1e18), + ("P", 1e15), + ("T", 1e12), + ("G", 1e9), + ("M", 1e6), + ("k", 1e3), + ("h", 1e2), + ("e", 1e1), + ("d", 1e-1), + ("c", 1e-2), + ("m", 1e-3), + ("u", 1e-6), + ("n", 1e-9), + ("p", 1e-12), + ("f", 1e-15), + ("a", 1e-18), + ("z", 1e-21), + ("y", 1e-24), +]; + +const BIN_PREFIXES: &[(&str, f64)] = &[ + ("ki", 1024.0), + ("Mi", 1_048_576.0), + ("Gi", 1_073_741_824.0), + ("Ti", 1_099_511_627_776.0), + ("Pi", 1_125_899_906_842_624.0), + ("Ei", 1_152_921_504_606_846_976.0), + ("Zi", 1_180_591_620_717_411_303_424.0), + ("Yi", 1_208_925_819_614_629_174_706_176.0), +]; + +fn find_unit(name: &str) -> Option<&'static Unit> { + UNITS.iter().find(|u| u.names.contains(&name)) +} + +/// Resolves a unit name to (unit, prefix multiplier). Exact names win over prefixed ones. +fn resolve(name: &str) -> Option<(&'static Unit, f64)> { + let name = name.replace("^2", "2").replace("^3", "3"); + if let Some(u) = find_unit(&name) { + return Some((u, 1.0)); + } + for (p, f) in BIN_PREFIXES { + if let Some(rest) = name.strip_prefix(p) + && let Some(u) = find_unit(rest) + && u.pre == Pre::Bin + { + return Some((u, *f)); + } + } + for (p, f) in SI_PREFIXES { + if let Some(rest) = name.strip_prefix(p) + && let Some(u) = find_unit(rest) + { + match u.pre { + Pre::Si(pow) => return Some((u, f.powi(pow))), + Pre::Bin => return Some((u, *f)), + Pre::No => {} + } + } + } + None +} + +fn to_kelvin(name: &str, x: f64) -> f64 { + match name { + "C" | "cel" => x + 273.15, + "F" | "fah" => (x - 32.0) * 5.0 / 9.0 + 273.15, + "Rank" => x * 5.0 / 9.0, + "Reau" => x * 1.25 + 273.15, + _ => x, + } +} + +fn from_kelvin(name: &str, k: f64) -> f64 { + match name { + "C" | "cel" => k - 273.15, + "F" | "fah" => (k - 273.15) * 9.0 / 5.0 + 32.0, + "Rank" => k * 9.0 / 5.0, + "Reau" => (k - 273.15) / 1.25, + _ => k, + } +} + +fn convert(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let from = text(a, 1)?; + let to = text(a, 2)?; + let (fu, fp) = resolve(&from).ok_or(CellError::NA)?; + let (tu, tp) = resolve(&to).ok_or(CellError::NA)?; + if fu.dim != tu.dim { + return Err(CellError::NA); + } + if fu.dim == Dim::Temperature { + let fname = fu.names.first().copied().unwrap_or("K"); + let tname = tu.names.first().copied().unwrap_or("K"); + if fname == tname && fp == tp { + return num_val(x); + } + let k = to_kelvin(fname, x * fp); + return num_val(from_kelvin(tname, k) / tp); + } + let base = x * fu.factor * fp; + num_val(base / (tu.factor * tp)) +} + +// --------------------------------------------------------------------------------------------- +// DELTA, GESTEP, ERF + +fn delta(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let y = opt_num(a, 1, 0.0)?; + Ok(Value::Number(if x == y { 1.0 } else { 0.0 })) +} +fn gestep(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let y = opt_num(a, 1, 0.0)?; + Ok(Value::Number(if x >= y { 1.0 } else { 0.0 })) +} +fn erf_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { + let lo = num(a, 0)?; + if has(a, 1) { + let hi = num(a, 1)?; + return num_val(erf(hi) - erf(lo)); + } + num_val(erf(lo)) +} +fn erf_precise(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(erf(num(a, 0)?)) +} +fn erfc_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(erfc(num(a, 0)?)) +} + +// --------------------------------------------------------------------------------------------- +// Complex numbers + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Cx { + re: f64, + im: f64, +} + +impl Cx { + fn new(re: f64, im: f64) -> Cx { + Cx { re, im } + } + fn add(self, o: Cx) -> Cx { + Cx::new(self.re + o.re, self.im + o.im) + } + fn sub(self, o: Cx) -> Cx { + Cx::new(self.re - o.re, self.im - o.im) + } + fn mul(self, o: Cx) -> Cx { + Cx::new(self.re * o.re - self.im * o.im, self.re * o.im + self.im * o.re) + } + fn div(self, o: Cx) -> R { + let d = o.re * o.re + o.im * o.im; + if d == 0.0 { + return Err(CellError::Num); + } + Ok(Cx::new((self.re * o.re + self.im * o.im) / d, (self.im * o.re - self.re * o.im) / d)) + } + fn abs(self) -> f64 { + self.re.hypot(self.im) + } + fn arg(self) -> f64 { + self.im.atan2(self.re) + } + fn exp(self) -> Cx { + let e = self.re.exp(); + Cx::new(e * self.im.cos(), e * self.im.sin()) + } + fn ln(self) -> R { + if self.re == 0.0 && self.im == 0.0 { + return Err(CellError::Num); + } + Ok(Cx::new(self.abs().ln(), self.arg())) + } + fn sin(self) -> Cx { + Cx::new(self.re.sin() * self.im.cosh(), self.re.cos() * self.im.sinh()) + } + fn cos(self) -> Cx { + Cx::new(self.re.cos() * self.im.cosh(), -self.re.sin() * self.im.sinh()) + } + fn sinh(self) -> Cx { + Cx::new(self.re.sinh() * self.im.cos(), self.re.cosh() * self.im.sin()) + } + fn cosh(self) -> Cx { + Cx::new(self.re.cosh() * self.im.cos(), self.re.sinh() * self.im.sin()) + } + fn powf(self, n: f64) -> R { + if self.re == 0.0 && self.im == 0.0 { + return if n > 0.0 { Ok(Cx::new(0.0, 0.0)) } else { Err(CellError::Num) }; + } + let r = self.abs().powf(n); + let t = self.arg() * n; + Ok(Cx::new(r * t.cos(), r * t.sin())) + } +} + +const ONE: Cx = Cx { re: 1.0, im: 0.0 }; + +/// Parses a real number written in a complex string (no spaces, optional exponent). +fn parse_real(s: &str) -> Option { + if s.is_empty() || s.contains(char::is_whitespace) { + return None; + } + let ok = s.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')); + if !ok { + return None; + } + s.parse::().ok().filter(|v| v.is_finite()) +} + +/// Parses "a+bi" forms. Returns the number and its suffix ('i', 'j' or none). +fn parse_complex(s: &str) -> R<(Cx, Option)> { + if s.is_empty() { + return Ok((Cx::new(0.0, 0.0), None)); + } + let suffix = match s.chars().last() { + Some(c @ ('i' | 'j')) => Some(c), + _ => None, + }; + let Some(sfx) = suffix else { + return parse_real(s).map(|r| (Cx::new(r, 0.0), None)).ok_or(CellError::Num); + }; + let body = &s[..s.len() - 1]; + // Split at the last sign that is not at the start and not part of an exponent. + let bytes = body.as_bytes(); + let mut split = None; + for i in (1..bytes.len()).rev() { + let c = bytes.get(i).copied().unwrap_or(0); + let prev = bytes.get(i - 1).copied().unwrap_or(0); + if (c == b'+' || c == b'-') && prev != b'e' && prev != b'E' { + split = Some(i); + break; + } + } + let (re_s, im_s) = match split { + Some(i) => (&body[..i], &body[i..]), + None => ("", body), + }; + let re = if re_s.is_empty() { 0.0 } else { parse_real(re_s).ok_or(CellError::Num)? }; + let im = match im_s { + "" | "+" => 1.0, + "-" => -1.0, + t => parse_real(t).ok_or(CellError::Num)?, + }; + Ok((Cx::new(re, im), Some(sfx))) +} + +fn cx_of(v: &Value) -> R<(Cx, Option)> { + match v { + Value::Number(n) => Ok((Cx::new(*n, 0.0), None)), + Value::Empty => Ok((Cx::new(0.0, 0.0), None)), + Value::Bool(_) => Err(CellError::Value), + Value::Error(e) => Err(*e), + Value::Text(t) => parse_complex(t), + Value::Array(a) => cx_of(a.data.first().unwrap_or(&Value::Empty)), + } +} + +fn cx_arg(a: &[Arg], i: usize) -> R<(Cx, Option)> { + cx_of(&a.get(i).map(scalar).unwrap_or(Value::Empty)) +} + +fn merge_suffix(acc: Option, s: Option) -> R> { + match (acc, s) { + (Some(x), Some(y)) if x != y => Err(CellError::Value), + (Some(x), _) => Ok(Some(x)), + (None, y) => Ok(y), + } +} + +fn fmt_complex(z: Cx, suffix: char) -> R { + if !z.re.is_finite() || !z.im.is_finite() { + return Err(CellError::Num); + } + let re = clean(z.re); + let im = clean(z.im); + let mut s = String::new(); + if im == 0.0 { + s.push_str(&number_to_text(re)); + return text_val(s); + } + if re != 0.0 { + s.push_str(&number_to_text(re)); + if im > 0.0 { + s.push('+'); + } + } + if im == -1.0 { + s.push('-'); + } else if im != 1.0 { + s.push_str(&number_to_text(im)); + } + s.push(suffix); + text_val(s) +} + +/// Rounds to 15 significant digits so values like 0.999999999999999999 print as 1. +fn clean(x: f64) -> f64 { + let s = format!("{:.14e}", x); + s.parse().unwrap_or(x) +} + +fn complex(a: &[Arg], _c: &mut dyn Ctx) -> R { + let re = num(a, 0)?; + let im = num(a, 1)?; + let sfx = if has(a, 2) { text(a, 2)? } else { "i".into() }; + let sfx = match sfx.as_str() { + "i" | "" => 'i', + "j" => 'j', + _ => return Err(CellError::Value), + }; + fmt_complex(Cx::new(re, im), sfx) +} + +fn im_unary(a: &[Arg], f: fn(Cx) -> R) -> R { + let (z, s) = cx_arg(a, 0)?; + fmt_complex(f(z)?, s.unwrap_or('i')) +} + +fn im_real_fn(a: &[Arg], f: fn(Cx) -> R) -> R { + let (z, _) = cx_arg(a, 0)?; + num_val(f(z)?) +} + +macro_rules! im1 { + ($name:ident, $f:expr) => { + fn $name(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_unary(a, $f) + } + }; +} + +im1!(imconjugate, |z: Cx| Ok(Cx::new(z.re, -z.im))); +im1!(imexp, |z: Cx| Ok(z.exp())); +im1!(imln, |z: Cx| z.ln()); +im1!(imlog10, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_10, l.im / std::f64::consts::LN_10))); +im1!(imlog2, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_2, l.im / std::f64::consts::LN_2))); +im1!(imsqrt, |z: Cx| z.powf(0.5)); +im1!(imsin, |z: Cx| Ok(z.sin())); +im1!(imcos, |z: Cx| Ok(z.cos())); +im1!(imtan, |z: Cx| z.sin().div(z.cos())); +im1!(imcot, |z: Cx| z.cos().div(z.sin())); +im1!(imsec, |z: Cx| ONE.div(z.cos())); +im1!(imcsc, |z: Cx| ONE.div(z.sin())); +im1!(imsinh, |z: Cx| Ok(z.sinh())); +im1!(imcosh, |z: Cx| Ok(z.cosh())); +im1!(imsech, |z: Cx| ONE.div(z.cosh())); +im1!(imcsch, |z: Cx| ONE.div(z.sinh())); + +fn imabs(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| Ok(z.abs())) +} +fn imreal(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| Ok(z.re)) +} +fn imaginary(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| Ok(z.im)) +} +fn imargument(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| if z.re == 0.0 && z.im == 0.0 { Err(CellError::Div0) } else { Ok(z.arg()) }) +} + +fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R) -> R { + let (x, s1) = cx_arg(a, 0)?; + let (y, s2) = cx_arg(a, 1)?; + let s = merge_suffix(s1, s2)?; + fmt_complex(f(x, y)?, s.unwrap_or('i')) +} +fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_binary(a, |x, y| x.div(y)) +} +fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_binary(a, |x, y| Ok(x.sub(y))) +} +fn impower(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (z, s) = cx_arg(a, 0)?; + let n = num(a, 1)?; + // Integer powers by repeated multiplication are exact for small Gaussian integers. + let r = if n == n.trunc() && n.abs() <= 64.0 && !(z.re == 0.0 && z.im == 0.0) { + let mut acc = ONE; + for _ in 0..(n.abs() as u32) { + acc = acc.mul(z); + } + if n < 0.0 { ONE.div(acc)? } else { acc } + } else { + z.powf(n)? + }; + fmt_complex(r, s.unwrap_or('i')) +} + +fn im_fold(a: &[Arg], init: Cx, f: fn(Cx, Cx) -> Cx) -> R { + let mut acc = init; + let mut sfx = None; + for arg in a { + let direct = !matches!(arg.value, Value::Array(_)); + for v in flatten(std::slice::from_ref(arg)) { + if matches!(v, Value::Empty) && !direct { + continue; + } + let (z, s) = cx_of(&v)?; + sfx = merge_suffix(sfx, s)?; + acc = f(acc, z); + } + } + fmt_complex(acc, sfx.unwrap_or('i')) +} +fn imsum(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_fold(a, Cx::new(0.0, 0.0), Cx::add) +} +fn improduct(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_fold(a, ONE, Cx::mul) +} + +// --------------------------------------------------------------------------------------------- +// Bessel functions + +const MAX_BESSEL_POINTS: f64 = 4_000_000.0; + +/// J_n(x) for integer n ≥ 0 via Bessel's integral, J_n(x) = (1/2π)∫₀^{2π} cos(nτ − x sin τ) dτ, +/// evaluated with the trapezoid rule, which converges exponentially for periodic integrands. +fn bessel_j(n: u32, x: f64) -> Option { + let pts = (2.0 * (x.abs() + n as f64) + 64.0).ceil(); + if pts > MAX_BESSEL_POINTS { + return if x.abs() > 1e6 { hankel(n, x.abs()).map(|(j, _)| if x < 0.0 && n % 2 == 1 { -j } else { j }) } else { None }; + } + let m = pts as usize; + let h = 2.0 * PI / m as f64; + let nf = n as f64; + let mut sum = 0.0; + for k in 0..m { + let t = k as f64 * h; + sum += (nf * t - x * t.sin()).cos(); + } + Some(sum / m as f64) +} + +/// Hankel asymptotic expansion: (J_n(x), Y_n(x)) for large x. +fn hankel(n: u32, x: f64) -> Option<(f64, f64)> { + let mu = 4.0 * (n as f64) * (n as f64); + let mut p = 0.0; + let mut q = 0.0; + let mut term: f64 = 1.0; + let mut last = f64::INFINITY; + for k in 0..200 { + if term.abs() > last { + break; + } + let t = if k % 4 < 2 { term } else { -term }; + if k % 2 == 0 { + p += t; + } else { + q += t; + } + last = term.abs(); + if last < 1e-17 { + break; + } + let kk = (k + 1) as f64; + let odd = 2.0 * kk - 1.0; + term *= (mu - odd * odd) / (kk * 8.0 * x); + } + let chi = x - (n as f64 / 2.0 + 0.25) * PI; + let f = (2.0 / (PI * x)).sqrt(); + let j = f * (p * chi.cos() - q * chi.sin()); + let y = f * (p * chi.sin() + q * chi.cos()); + if j.is_finite() && y.is_finite() { Some((j, y)) } else { None } +} + +const EULER: f64 = std::f64::consts::EULER_GAMMA; + +/// J_0..J_m(x) for 0 < x ≤ ~100 by Miller's backward recurrence, normalised with +/// J_0 + 2ΣJ_{2k} = 1. +fn bessel_j_all(x: f64) -> Vec { + let top = x.ceil() as usize + 30 + (40.0 * x.max(1.0)).sqrt() as usize; + let m = top + top % 2; + let mut j = vec![0.0f64; m + 2]; + if let Some(v) = j.get_mut(m) { + *v = 1e-30; + } + for k in (1..=m).rev() { + let jk = j.get(k).copied().unwrap_or(0.0); + let jk1 = j.get(k + 1).copied().unwrap_or(0.0); + let mut prev = 2.0 * k as f64 / x * jk - jk1; + if prev.abs() > 1e250 { + for v in j.iter_mut() { + *v *= 1e-250; + } + prev *= 1e-250; + } + if let Some(v) = j.get_mut(k - 1) { + *v = prev; + } + } + let norm: f64 = j.first().copied().unwrap_or(0.0) + 2.0 * j.iter().skip(2).step_by(2).sum::(); + if norm != 0.0 { + for v in j.iter_mut() { + *v /= norm; + } + } + j +} + +/// Y_0 and Y_1 by Neumann series over J_k (x ≤ 25) or the asymptotic expansion. +fn bessel_y01(x: f64) -> Option<(f64, f64)> { + if x > 25.0 { + let (_, y0) = hankel(0, x)?; + let (_, y1) = hankel(1, x)?; + return Some((y0, y1)); + } + let j = bessel_j_all(x); + let jj = |k: usize| j.get(k).copied().unwrap_or(0.0); + let l = (x / 2.0).ln() + EULER; + // Y0 = (2/π)(L J0) − (4/π) Σ (−1)^k J_{2k}/k + // Y1 = −Y0' = −(2/π)(J0/x − L J1) + (2/π) Σ (−1)^k (J_{2k−1} − J_{2k+1})/k + let mut s0 = 0.0; + let mut s1 = 0.0; + let mut k = 1usize; + while 2 * k + 1 < j.len() { + let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; + s0 += sign * jj(2 * k) / k as f64; + s1 += sign * (jj(2 * k - 1) - jj(2 * k + 1)) / k as f64; + k += 1; + } + let y0 = (2.0 / PI) * l * jj(0) - (4.0 / PI) * s0; + let y1 = -(2.0 / PI) * (jj(0) / x - l * jj(1)) + (2.0 / PI) * s1; + if y0.is_finite() && y1.is_finite() { Some((y0, y1)) } else { None } +} + +fn bessel_y(n: u32, x: f64) -> Option { + let (y0, y1) = bessel_y01(x)?; + if n == 0 { + return Some(y0); + } + let (mut a, mut b) = (y0, y1); + for k in 1..n { + let c = 2.0 * k as f64 / x * b - a; + a = b; + b = c; + if !b.is_finite() { + return None; + } + } + Some(b) +} + +/// I_n(x) by its power series (all terms positive), computed with a log-scaled first term. +fn bessel_i(n: u32, x: f64) -> Option { + if x == 0.0 { + return Some(if n == 0 { 1.0 } else { 0.0 }); + } + let ax = x.abs(); + let nf = n as f64; + let ln_t0 = nf * (ax / 2.0).ln() - crate::special::ln_gamma(nf + 1.0); + let q = ax * ax / 4.0; + let mut term = 1.0; + let mut sum = 1.0; + let mut k = 0.0; + while k < 1e6 { + k += 1.0; + term *= q / (k * (k + nf)); + sum += term; + if term < 1e-17 * sum { + break; + } + if !sum.is_finite() { + return None; + } + } + let r = (ln_t0 + sum.ln()).exp(); + let r = if x < 0.0 && n % 2 == 1 { -r } else { r }; + if r.is_finite() { Some(r) } else { None } +} + +/// K_n(x) for x > 0: K_0 and K_1 from ∫₀^∞ e^{−x cosh t} cosh(νt) dt (trapezoid rule), then +/// upward recurrence. +fn bessel_k(n: u32, x: f64) -> Option { + let k_int = |nu: f64| -> f64 { + let h: f64 = 0.02; + let mut sum = 0.5 * (-x).exp(); + let mut t: f64 = h; + while t < 800.0 { + let v = (-x * t.cosh() + (nu * t).ln_cosh_safe()).exp(); + sum += v; + if v < 1e-18 * sum && x * t.cosh() > 50.0 { + break; + } + t += h; + } + sum * h + }; + let k0 = k_int(0.0); + if n == 0 { + return Some(k0); + } + let k1 = k_int(1.0); + let (mut a, mut b) = (k0, k1); + for k in 1..n { + let c = a + 2.0 * k as f64 / x * b; + a = b; + b = c; + if !b.is_finite() { + return None; + } + } + if b.is_finite() { Some(b) } else { None } +} + +trait LnCosh { + fn ln_cosh_safe(self) -> f64; +} +impl LnCosh for f64 { + /// ln(cosh(x)) without overflow. + fn ln_cosh_safe(self) -> f64 { + let a = self.abs(); + a + (-2.0 * a).exp().ln_1p() - std::f64::consts::LN_2 + } +} + +fn bessel_args(a: &[Arg]) -> R<(f64, u32)> { + let x = num(a, 0)?; + let n = num(a, 1)?.trunc(); + if n < 0.0 { + return Err(CellError::Num); + } + if n > 100_000.0 { + return Err(CellError::Num); + } + Ok((x, n as u32)) +} + +fn besselj(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + num_val(bessel_j(n, x).ok_or(CellError::Num)?) +} +fn bessely(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + if x <= 0.0 { + return Err(CellError::Num); + } + num_val(bessel_y(n, x).ok_or(CellError::Num)?) +} +fn besseli(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + num_val(bessel_i(n, x).ok_or(CellError::Num)?) +} +fn besselk(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + if x <= 0.0 { + return Err(CellError::Num); + } + num_val(bessel_k(n, x).ok_or(CellError::Num)?) +} + +pub(crate) fn specs() -> Vec { + vec![ + f!("BIN2DEC", 1, 1, Engineering, S, "BIN2DEC(number)", "Converts a binary number to decimal.", bin2dec), + f!("BIN2HEX", 1, 2, Engineering, S, "BIN2HEX(number, [places])", "Converts a binary number to hexadecimal.", bin2hex), + f!("BIN2OCT", 1, 2, Engineering, S, "BIN2OCT(number, [places])", "Converts a binary number to octal.", bin2oct), + f!("DEC2BIN", 1, 2, Engineering, S, "DEC2BIN(number, [places])", "Converts a decimal number to binary.", dec2bin), + f!("DEC2HEX", 1, 2, Engineering, S, "DEC2HEX(number, [places])", "Converts a decimal number to hexadecimal.", dec2hex), + f!("DEC2OCT", 1, 2, Engineering, S, "DEC2OCT(number, [places])", "Converts a decimal number to octal.", dec2oct), + f!("HEX2BIN", 1, 2, Engineering, S, "HEX2BIN(number, [places])", "Converts a hexadecimal number to binary.", hex2bin), + f!("HEX2DEC", 1, 1, Engineering, S, "HEX2DEC(number)", "Converts a hexadecimal number to decimal.", hex2dec), + f!("HEX2OCT", 1, 2, Engineering, S, "HEX2OCT(number, [places])", "Converts a hexadecimal number to octal.", hex2oct), + f!("OCT2BIN", 1, 2, Engineering, S, "OCT2BIN(number, [places])", "Converts an octal number to binary.", oct2bin), + f!("OCT2DEC", 1, 1, Engineering, S, "OCT2DEC(number)", "Converts an octal number to decimal.", oct2dec), + f!("OCT2HEX", 1, 2, Engineering, S, "OCT2HEX(number, [places])", "Converts an octal number to hexadecimal.", oct2hex), + f!("BITAND", 2, 2, Engineering, S, "BITAND(number1, number2)", "Bitwise AND of two non-negative integers.", bitand), + f!("BITOR", 2, 2, Engineering, S, "BITOR(number1, number2)", "Bitwise OR of two non-negative integers.", bitor), + f!("BITXOR", 2, 2, Engineering, S, "BITXOR(number1, number2)", "Bitwise exclusive OR of two non-negative integers.", bitxor), + f!( + "BITLSHIFT", + 2, + 2, + Engineering, + S, + "BITLSHIFT(number, shift_amount)", + "Shifts the bits of a number left (right for negative shifts).", + bitlshift + ), + f!( + "BITRSHIFT", + 2, + 2, + Engineering, + S, + "BITRSHIFT(number, shift_amount)", + "Shifts the bits of a number right (left for negative shifts).", + bitrshift + ), + f!("CONVERT", 3, 3, Engineering, S, "CONVERT(number, from_unit, to_unit)", "Converts a measurement from one unit to another.", convert), + f!("DELTA", 1, 2, Engineering, S, "DELTA(number1, [number2])", "Returns 1 when two numbers are equal, otherwise 0.", delta), + f!("GESTEP", 1, 2, Engineering, S, "GESTEP(number, [step])", "Returns 1 when a number is at least the step, otherwise 0.", gestep), + f!( + "ERF", + 1, + 2, + Engineering, + S, + "ERF(lower_limit, [upper_limit])", + "Error function integrated from 0 to a limit, or between two limits.", + erf_fn + ), + f!("ERF.PRECISE", 1, 1, Engineering, S, "ERF.PRECISE(x)", "Error function integrated from 0 to x.", erf_precise), + f!("ERFC", 1, 1, Engineering, S, "ERFC(x)", "Complementary error function, 1 - ERF(x).", erfc_fn), + f!("ERFC.PRECISE", 1, 1, Engineering, S, "ERFC.PRECISE(x)", "Complementary error function, 1 - ERF(x).", erfc_fn), + f!( + "COMPLEX", + 2, + 3, + Engineering, + S, + "COMPLEX(real_num, i_num, [suffix])", + "Builds a complex number as text from real and imaginary parts.", + complex + ), + f!("IMABS", 1, 1, Engineering, S, "IMABS(inumber)", "Absolute value (modulus) of a complex number.", imabs), + f!("IMAGINARY", 1, 1, Engineering, S, "IMAGINARY(inumber)", "Imaginary coefficient of a complex number.", imaginary), + f!("IMREAL", 1, 1, Engineering, S, "IMREAL(inumber)", "Real coefficient of a complex number.", imreal), + f!("IMARGUMENT", 1, 1, Engineering, S, "IMARGUMENT(inumber)", "Angle of a complex number in radians.", imargument), + f!("IMCONJUGATE", 1, 1, Engineering, S, "IMCONJUGATE(inumber)", "Complex conjugate of a complex number.", imconjugate), + f!("IMCOS", 1, 1, Engineering, S, "IMCOS(inumber)", "Cosine of a complex number.", imcos), + f!("IMCOSH", 1, 1, Engineering, S, "IMCOSH(inumber)", "Hyperbolic cosine of a complex number.", imcosh), + f!("IMCOT", 1, 1, Engineering, S, "IMCOT(inumber)", "Cotangent of a complex number.", imcot), + f!("IMCSC", 1, 1, Engineering, S, "IMCSC(inumber)", "Cosecant of a complex number.", imcsc), + f!("IMCSCH", 1, 1, Engineering, S, "IMCSCH(inumber)", "Hyperbolic cosecant of a complex number.", imcsch), + f!("IMDIV", 2, 2, Engineering, S, "IMDIV(inumber1, inumber2)", "Quotient of two complex numbers.", imdiv), + f!("IMEXP", 1, 1, Engineering, S, "IMEXP(inumber)", "Exponential of a complex number.", imexp), + f!("IMLN", 1, 1, Engineering, S, "IMLN(inumber)", "Natural logarithm of a complex number.", imln), + f!("IMLOG10", 1, 1, Engineering, S, "IMLOG10(inumber)", "Base-10 logarithm of a complex number.", imlog10), + f!("IMLOG2", 1, 1, Engineering, S, "IMLOG2(inumber)", "Base-2 logarithm of a complex number.", imlog2), + f!("IMPOWER", 2, 2, Engineering, S, "IMPOWER(inumber, number)", "Raises a complex number to a power.", impower), + f!("IMPRODUCT", 1, VAR, Engineering, A, "IMPRODUCT(inumber1, [inumber2], ...)", "Product of complex numbers.", improduct), + f!("IMSEC", 1, 1, Engineering, S, "IMSEC(inumber)", "Secant of a complex number.", imsec), + f!("IMSECH", 1, 1, Engineering, S, "IMSECH(inumber)", "Hyperbolic secant of a complex number.", imsech), + f!("IMSIN", 1, 1, Engineering, S, "IMSIN(inumber)", "Sine of a complex number.", imsin), + f!("IMSINH", 1, 1, Engineering, S, "IMSINH(inumber)", "Hyperbolic sine of a complex number.", imsinh), + f!("IMSQRT", 1, 1, Engineering, S, "IMSQRT(inumber)", "Square root of a complex number.", imsqrt), + f!("IMSUB", 2, 2, Engineering, S, "IMSUB(inumber1, inumber2)", "Difference of two complex numbers.", imsub), + f!("IMSUM", 1, VAR, Engineering, A, "IMSUM(inumber1, [inumber2], ...)", "Sum of complex numbers.", imsum), + f!("IMTAN", 1, 1, Engineering, S, "IMTAN(inumber)", "Tangent of a complex number.", imtan), + f!("BESSELJ", 2, 2, Engineering, S, "BESSELJ(x, n)", "Bessel function of the first kind, J_n(x).", besselj), + f!("BESSELY", 2, 2, Engineering, S, "BESSELY(x, n)", "Bessel function of the second kind, Y_n(x).", bessely), + f!("BESSELI", 2, 2, Engineering, S, "BESSELI(x, n)", "Modified Bessel function of the first kind, I_n(x).", besseli), + f!("BESSELK", 2, 2, Engineering, S, "BESSELK(x, n)", "Modified Bessel function of the second kind, K_n(x).", besselk), + ] +} + +#[cfg(test)] +mod tests { + use sheetcraft_core::{CellError, Value}; + + use super::parse_complex; + use crate::util::testutil::*; + + #[track_caller] + fn cx(v: Value, re: f64, im: f64) { + let Value::Text(s) = &v else { panic!("expected complex text, got {v:?}") }; + let (z, _) = parse_complex(s).unwrap(); + let tol = 1e-12; + assert!((z.re - re).abs() <= tol * re.abs().max(1.0), "{s}: re {} vs {re}", z.re); + assert!((z.im - im).abs() <= tol * im.abs().max(1.0), "{s}: im {} vs {im}", z.im); + } + + #[test] + fn base_conversions() { + close(ev("BIN2DEC", vec![n(1100100.0)]), 100.0); + close(ev("BIN2DEC", vec![n(1111111111.0)]), -1.0); + is_text(ev("BIN2HEX", vec![n(11111011.0), n(4.0)]), "00FB"); + is_text(ev("BIN2HEX", vec![n(1110.0)]), "E"); + is_text(ev("BIN2HEX", vec![n(1111111111.0)]), "FFFFFFFFFF"); + is_text(ev("BIN2OCT", vec![n(1001.0), n(3.0)]), "011"); + is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777"); + is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001"); + is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100"); + is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num); + is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064"); + is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA"); + is_text(ev("DEC2HEX", vec![n(28.0)]), "1C"); + is_err(ev("DEC2HEX", vec![n(64.0), n(1.0)]), CellError::Num); + is_text(ev("DEC2OCT", vec![n(58.0), n(3.0)]), "072"); + is_text(ev("DEC2OCT", vec![n(-100.0)]), "7777777634"); + is_text(ev("HEX2BIN", vec![t("F"), n(8.0)]), "00001111"); + is_text(ev("HEX2BIN", vec![t("B7")]), "10110111"); + is_text(ev("HEX2BIN", vec![t("FFFFFFFE00")]), "1000000000"); + is_err(ev("HEX2BIN", vec![t("200")]), CellError::Num); + close(ev("HEX2DEC", vec![t("A5")]), 165.0); + close(ev("HEX2DEC", vec![t("FFFFFFFF5B")]), -165.0); + close(ev("HEX2DEC", vec![t("3DA408B9")]), 1034160313.0); + is_text(ev("HEX2OCT", vec![t("F"), n(3.0)]), "017"); + is_text(ev("HEX2OCT", vec![t("3B4E")]), "35516"); + is_text(ev("HEX2OCT", vec![t("FFFFFFFF00")]), "7777777400"); + is_text(ev("OCT2BIN", vec![n(3.0), n(3.0)]), "011"); + is_text(ev("OCT2BIN", vec![n(7777777000.0)]), "1000000000"); + close(ev("OCT2DEC", vec![n(54.0)]), 44.0); + close(ev("OCT2DEC", vec![n(7777777533.0)]), -165.0); + is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040"); + is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B"); + is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num); + is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num); + is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num); + is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value); + is_text(ev("DEC2BIN", vec![n(9.7)]), "1001"); + close(ev("HEX2DEC", vec![t("ff")]), 255.0); + } + + #[test] + fn bits() { + close(ev("BITAND", vec![n(1.0), n(5.0)]), 1.0); + close(ev("BITAND", vec![n(13.0), n(25.0)]), 9.0); + close(ev("BITOR", vec![n(23.0), n(10.0)]), 31.0); + close(ev("BITXOR", vec![n(5.0), n(3.0)]), 6.0); + close(ev("BITLSHIFT", vec![n(4.0), n(2.0)]), 16.0); + close(ev("BITRSHIFT", vec![n(13.0), n(2.0)]), 3.0); + close(ev("BITRSHIFT", vec![n(13.0), n(-2.0)]), 52.0); + is_err(ev("BITAND", vec![n(-1.0), n(5.0)]), CellError::Num); + is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num); + is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num); + is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num); + } + + #[test] + fn convert_units() { + close(ev("CONVERT", vec![n(1.0), t("lbm"), t("kg")]), 0.45359237); + close(ev("CONVERT", vec![n(68.0), t("F"), t("C")]), 20.0); + close(ev("CONVERT", vec![n(100.0), t("C"), t("F")]), 212.0); + close(ev("CONVERT", vec![n(0.0), t("C"), t("K")]), 273.15); + close(ev("CONVERT", vec![n(1.0), t("in"), t("cm")]), 2.54); + close(ev("CONVERT", vec![n(1.0), t("mi"), t("km")]), 1.609344); + close(ev("CONVERT", vec![n(1.0), t("gal"), t("l")]), 3.785411784); + close(ev("CONVERT", vec![n(1.0), t("byte"), t("bit")]), 8.0); + close(ev("CONVERT", vec![n(1.0), t("kibyte"), t("byte")]), 1024.0); + close(ev("CONVERT", vec![n(100.0), t("km/h"), t("m/s")]), 100.0 / 3.6); + close(ev("CONVERT", vec![n(1.0), t("m2"), t("ft2")]), 10.763910416709722); + close(ev("CONVERT", vec![n(1.0), t("km2"), t("m2")]), 1e6); + close(ev("CONVERT", vec![n(1.0), t("hr"), t("mn")]), 60.0); + close(ev("CONVERT", vec![n(1.0), t("atm"), t("Pa")]), 101325.0); + close(ev("CONVERT", vec![n(1.0), t("ha"), t("m^2")]), 10000.0); + close(ev("CONVERT", vec![n(1.0), t("cal"), t("J")]), 4.1868); + close(ev("CONVERT", vec![n(1.0), t("mm"), t("m")]), 0.001); + close(ev("CONVERT", vec![n(6.0), t("ft"), t("yd")]), 2.0); + close(ev("CONVERT", vec![n(1.0), t("cup"), t("tsp")]), 48.0); + is_err(ev("CONVERT", vec![n(2.5), t("ft"), t("sec")]), CellError::NA); + is_err(ev("CONVERT", vec![n(1.0), t("xyz"), t("m")]), CellError::NA); + is_err(ev("CONVERT", vec![n(1.0), t("kft"), t("m")]), CellError::NA); + is_err(ev("CONVERT", vec![n(1.0), t("LBM"), t("kg")]), CellError::NA); + } + + #[test] + fn delta_gestep_erf() { + close(ev("DELTA", vec![n(5.0), n(4.0)]), 0.0); + close(ev("DELTA", vec![n(5.0), n(5.0)]), 1.0); + close(ev("DELTA", vec![n(0.5)]), 0.0); + close(ev("GESTEP", vec![n(5.0), n(4.0)]), 1.0); + close(ev("GESTEP", vec![n(-4.0)]), 0.0); + close_tol(ev("ERF", vec![n(0.745)]), 0.707_928_920, 1e-9); + close_tol(ev("ERF", vec![n(1.0)]), 0.842_700_792_949_715, 1e-12); + close_tol(ev("ERF", vec![n(0.0), n(1.5)]), 0.966_105_146_475_311, 1e-12); + close_tol(ev("ERF.PRECISE", vec![n(1.0)]), 0.842_700_792_949_715, 1e-12); + close_tol(ev("ERFC", vec![n(1.0)]), 0.157_299_207_050_285, 1e-12); + close_tol(ev("ERFC.PRECISE", vec![n(-1.0)]), 1.842_700_792_949_715, 1e-12); + } + + #[test] + fn complex_numbers() { + is_text(ev("COMPLEX", vec![n(3.0), n(4.0)]), "3+4i"); + is_text(ev("COMPLEX", vec![n(3.0), n(4.0), t("j")]), "3+4j"); + is_text(ev("COMPLEX", vec![n(0.0), n(1.0)]), "i"); + is_text(ev("COMPLEX", vec![n(0.0), n(-1.0)]), "-i"); + is_text(ev("COMPLEX", vec![n(1.5), n(-2.0)]), "1.5-2i"); + is_text(ev("COMPLEX", vec![n(3.0), n(0.0)]), "3"); + is_text(ev("COMPLEX", vec![n(0.0), n(0.0)]), "0"); + is_err(ev("COMPLEX", vec![n(1.0), n(1.0), t("k")]), CellError::Value); + close(ev("IMABS", vec![t("5+12i")]), 13.0); + close(ev("IMREAL", vec![t("6-9i")]), 6.0); + close(ev("IMAGINARY", vec![t("3+4i")]), 4.0); + close(ev("IMAGINARY", vec![t("-j")]), -1.0); + close(ev("IMAGINARY", vec![t("4")]), 0.0); + close(ev("IMREAL", vec![n(7.0)]), 7.0); + close(ev("IMARGUMENT", vec![t("3+4i")]), 0.927_295_218_001_612_2); + is_err(ev("IMARGUMENT", vec![t("0")]), CellError::Div0); + is_text(ev("IMCONJUGATE", vec![t("3+4i")]), "3-4i"); + is_text(ev("IMDIV", vec![t("-238+240i"), t("10+24i")]), "5+12i"); + is_err(ev("IMDIV", vec![t("1+i"), t("0")]), CellError::Num); + is_text(ev("IMPRODUCT", vec![t("3+4i"), t("5-3i")]), "27+11i"); + is_text(ev("IMPRODUCT", vec![t("1+2i"), n(30.0)]), "30+60i"); + is_text(ev("IMSUM", vec![t("3+4i"), t("5-3i")]), "8+i"); + is_text(ev("IMSUM", vec![rf(arr(vec![vec![tv("1+i"), Value::Empty, tv("2")]]))]), "3+i"); + is_text(ev("IMSUB", vec![t("13+4i"), t("5+3i")]), "8+i"); + is_err(ev("IMSUM", vec![t("1+i"), t("1+j")]), CellError::Value); + is_err(ev("IMABS", vec![t("abc")]), CellError::Num); + is_err(ev("IMABS", vec![b(true)]), CellError::Value); + cx(ev("IMEXP", vec![t("1+i")]), 1.468_693_939_915_885_2, 2.287_355_287_178_842); + cx(ev("IMLN", vec![t("3+4i")]), 1.609_437_912_434_100_3, 0.927_295_218_001_612_2); + cx(ev("IMLOG10", vec![t("3+4i")]), 0.698_970_004_336_018_8, 0.402_719_196_273_373_2); + cx(ev("IMLOG2", vec![t("3+4i")]), 2.321_928_094_887_362, 1.337_804_212_450_976_8); + cx(ev("IMSQRT", vec![t("1+i")]), 1.098_684_113_467_809_9, 0.455_089_860_562_227_3); + cx(ev("IMSQRT", vec![t("-4")]), 0.0, 2.0); + is_text(ev("IMPOWER", vec![t("2+3i"), n(3.0)]), "-46+9i"); + cx(ev("IMPOWER", vec![t("2+3i"), n(0.5)]), 1.674_149_228_035_540_2, 0.895_977_476_129_838); + cx(ev("IMSIN", vec![t("3+4i")]), 3.853_738_037_919_377, -27.016_813_258_003_93); + cx(ev("IMCOS", vec![t("1+i")]), 0.833_730_025_131_149, -0.988_897_705_762_865); + cx(ev("IMSINH", vec![t("4+3i")]), -27.016_813_258_003_93, 3.853_738_037_919_377); + cx(ev("IMCOSH", vec![t("4+3i")]), -27.034_945_603_074_224, 3.851_153_334_811_777); + cx(ev("IMTAN", vec![t("4+3i")]), 0.004_908_258_067_496_06, 1.000_709_536_067_233); + cx(ev("IMCOT", vec![t("4+3i")]), 0.004_901_182_394_304_4, -0.999_266_927_805_902); + cx(ev("IMSEC", vec![t("4+3i")]), -0.065_294_027_857_947_1, -0.075_224_960_302_773_2); + cx(ev("IMCSC", vec![t("4+3i")]), -0.075_489_832_915_863_7, 0.064_877_471_370_635_5); + is_text(ev("IMSECH", vec![t("0")]), "1"); + is_err(ev("IMCSCH", vec![t("0")]), CellError::Num); + // sech z · cosh z = 1 + let Value::Text(c) = ev("IMCOSH", vec![t("4+3i")]) else { panic!() }; + let Value::Text(sc) = ev("IMSECH", vec![t("4+3i")]) else { panic!() }; + cx(ev("IMPRODUCT", vec![t(&c), t(&sc)]), 1.0, 0.0); + let Value::Text(sh) = ev("IMSINH", vec![t("4+3i")]) else { panic!() }; + let Value::Text(csh) = ev("IMCSCH", vec![t("4+3i")]) else { panic!() }; + cx(ev("IMPRODUCT", vec![t(&sh), t(&csh)]), 1.0, 0.0); + cx(ev("IMSUM", vec![t("1.5e1+2E-1j")]), 15.0, 0.2); + } + + #[test] + fn bessel() { + close_tol(ev("BESSELJ", vec![n(1.9), n(2.0)]), 0.329_925_829, 1e-6); + close_tol(ev("BESSELY", vec![n(2.5), n(1.0)]), 0.145_918_138, 1e-6); + close_tol(ev("BESSELI", vec![n(1.5), n(1.0)]), 0.981_666_428, 1e-6); + close_tol(ev("BESSELK", vec![n(1.5), n(1.0)]), 0.277_387_804, 1e-6); + close_tol(ev("BESSELJ", vec![n(1.0), n(0.0)]), 0.765_197_686_557_966_6, 1e-12); + close_tol(ev("BESSELJ", vec![n(1.0), n(1.0)]), 0.440_050_585_744_933_5, 1e-12); + close_tol(ev("BESSELJ", vec![n(-1.0), n(1.0)]), -0.440_050_585_744_933_5, 1e-12); + close_tol(ev("BESSELY", vec![n(1.0), n(0.0)]), 0.088_256_964_215_676_96, 1e-11); + close_tol(ev("BESSELY", vec![n(1.0), n(1.0)]), -0.781_212_821_300_288_7, 1e-11); + close_tol(ev("BESSELI", vec![n(1.0), n(0.0)]), 1.266_065_877_752_008_4, 1e-12); + close_tol(ev("BESSELI", vec![n(1.0), n(1.0)]), 0.565_159_103_992_485_1, 1e-12); + close_tol(ev("BESSELK", vec![n(1.0), n(0.0)]), 0.421_024_438_240_708_3, 1e-11); + close_tol(ev("BESSELK", vec![n(1.0), n(1.0)]), 0.601_907_230_197_234_6, 1e-11); + is_err(ev("BESSELJ", vec![n(1.0), n(-1.0)]), CellError::Num); + is_err(ev("BESSELY", vec![n(0.0), n(1.0)]), CellError::Num); + is_err(ev("BESSELK", vec![n(-1.0), n(1.0)]), CellError::Num); + is_err(ev("BESSELJ", vec![t("x"), n(1.0)]), CellError::Value); + // Wronskian J_{1}Y_0 − J_0Y_1 = 2/(πx), checking series and asymptotic regimes. + for x in [0.5, 7.0, 15.0, 24.0, 30.0, 80.0] { + let j0 = match ev("BESSELJ", vec![n(x), n(0.0)]) { + Value::Number(v) => v, + _ => panic!(), + }; + let j1 = match ev("BESSELJ", vec![n(x), n(1.0)]) { + Value::Number(v) => v, + _ => panic!(), + }; + let y0 = match ev("BESSELY", vec![n(x), n(0.0)]) { + Value::Number(v) => v, + _ => panic!(), + }; + let y1 = match ev("BESSELY", vec![n(x), n(1.0)]) { + Value::Number(v) => v, + _ => panic!(), + }; + let w = j1 * y0 - j0 * y1; + let expect = 2.0 / (std::f64::consts::PI * x); + assert!((w - expect).abs() < 1e-9 * expect.max(1.0), "x={x}: {w} vs {expect}"); + } + // Large orders and arguments stay finite. + assert!(matches!(ev("BESSELJ", vec![n(50.0), n(3.0)]), Value::Number(_))); + assert!(matches!(ev("BESSELK", vec![n(30.0), n(5.0)]), Value::Number(_))); + } +} diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs new file mode 100644 index 00000000..5700908a --- /dev/null +++ b/crates/functions/src/financial.rs @@ -0,0 +1,1609 @@ +//! Financial functions: time value of money, cash-flow analysis, depreciation and securities. +//! +//! Sign convention follows Excel: money paid out is negative, money received is positive. +//! Date arguments are serial numbers (truncated) in the 1900 date system. + +use sheetcraft_core::date::{datetime_from_serial, days_in_month, serial_from_ymd}; +use sheetcraft_core::{CellError, DateSystem, Value}; + +use crate::util::{R, S, array_numbers, as_array, flatten, has, num, num_val, numbers, opt_bool, opt_num}; +use crate::{Arg, Ctx, FnSpec, VAR}; + +// --------------------------------------------------------------------------------------------- +// Time value of money +// --------------------------------------------------------------------------------------------- + +fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { + if rate == 0.0 { + return -(pv + fv) / nper; + } + let p = (1.0 + rate).powf(nper); + -(rate * (fv + pv * p)) / ((1.0 + rate * typ) * (p - 1.0)) +} + +fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 { + if rate == 0.0 { + return -(pv + pmt * nper); + } + let p = (1.0 + rate).powf(nper); + -(pv * p + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) +} + +fn pv_calc(rate: f64, nper: f64, pmt: f64, fv: f64, typ: f64) -> f64 { + if rate == 0.0 { + return -(fv + pmt * nper); + } + let p = (1.0 + rate).powf(nper); + -(fv + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) / p +} + +fn type_arg(args: &[Arg], i: usize) -> R { + Ok(if opt_num(args, i, 0.0)? != 0.0 { 1.0 } else { 0.0 }) +} + +fn pmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, nper, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); + if nper == 0.0 { + return Err(CellError::Num); + } + num_val(pmt_calc(rate, nper, pv, fv, typ)) +} + +fn fv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + num_val(fv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) +} + +fn pv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + num_val(pv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) +} + +fn nper(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); + if rate == 0.0 { + if pmt == 0.0 { + return Err(CellError::Num); + } + return num_val(-(pv + fv) / pmt); + } + let numer = pmt * (1.0 + rate * typ) - fv * rate; + let denom = pv * rate + pmt * (1.0 + rate * typ); + if denom == 0.0 { + return Err(CellError::Num); + } + let q = numer / denom; + if q <= 0.0 || rate <= -1.0 { + return Err(CellError::Num); + } + num_val(q.ln() / (1.0 + rate).ln()) +} + +fn ipmt_calc(rate: f64, per: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { + let p = pmt_calc(rate, nper, pv, fv, typ); + if typ == 1.0 && per == 1.0 { + return 0.0; + } + let bal = fv_calc(rate, per - 1.0, p, pv, typ); + let i = bal * rate; + if typ == 1.0 { i / (1.0 + rate) } else { i } +} + +fn ipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); + if per < 1.0 || per > np || np == 0.0 { + return Err(CellError::Num); + } + num_val(ipmt_calc(rate, per, np, pv, fv, typ)) +} + +fn ppmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); + if per < 1.0 || per > np || np == 0.0 { + return Err(CellError::Num); + } + num_val(pmt_calc(rate, np, pv, fv, typ) - ipmt_calc(rate, per, np, pv, fv, typ)) +} + +fn cumulative(a: &[Arg], principal: bool) -> R { + let (rate, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (start, end, typ) = (num(a, 3)?.trunc(), num(a, 4)?.trunc(), num(a, 5)?); + let np_i = np.trunc(); + if rate <= 0.0 || np_i <= 0.0 || pv <= 0.0 || start < 1.0 || end < start || end > np_i || (typ != 0.0 && typ != 1.0) { + return Err(CellError::Num); + } + if end - start > 1e7 || np_i > 1e15 { + return Err(CellError::Num); + } + let p = pmt_calc(rate, np_i, pv, 0.0, typ); + let mut sum = 0.0; + let mut per = start; + while per <= end { + let i = ipmt_calc(rate, per, np_i, pv, 0.0, typ); + sum += if principal { p - i } else { i }; + per += 1.0; + } + num_val(sum) +} + +fn cumipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + cumulative(a, false) +} + +fn cumprinc(a: &[Arg], _c: &mut dyn Ctx) -> R { + cumulative(a, true) +} + +/// Newton iteration with a numeric derivative, keeping the rate above -1. +fn newton(f: impl Fn(f64) -> f64, guess: f64) -> Option { + let mut x = guess; + for _ in 0..200 { + let y = f(x); + if !y.is_finite() { + return None; + } + let h = 1e-6 * x.abs().max(1e-3); + let d = (f(x + h) - f(x - h)) / (2.0 * h); + if d == 0.0 || !d.is_finite() { + return None; + } + let mut nx = x - y / d; + if nx <= -1.0 { + nx = (x - 1.0) / 2.0; + if nx <= -1.0 { + nx = -0.999_999_999; + } + } + if (nx - x).abs() < 1e-12 * nx.abs().max(1.0) { + return Some(nx); + } + x = nx; + } + None +} + +/// Tries Newton from the guess and a few fallback starting points; then bisection over a +/// bracket if one can be found. +fn solve_rate(f: impl Fn(f64) -> f64, guess: f64) -> R { + let check = |r: f64| f(r).is_finite() && f(r).abs() < 1e-7 * (1.0 + f(guess).abs().min(1e12)); + for g in [guess, 0.1, 0.01, 0.5, -0.5, 1.0, 0.001, 5.0, -0.9, 10.0] { + if let Some(r) = newton(&f, g) + && r > -1.0 + && check(r) + { + return Ok(r); + } + } + // Scan for a sign change and bisect. + let grid: Vec = [-0.999_999, -0.99, -0.9, -0.5, -0.2, 0.0, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 100.0, 1000.0].to_vec(); + for w in grid.windows(2) { + let (mut lo, mut hi) = match w { + [a, b] => (*a, *b), + _ => continue, + }; + let (flo, fhi) = (f(lo), f(hi)); + if !(flo.is_finite() && fhi.is_finite()) || flo * fhi > 0.0 { + continue; + } + let lo_neg = flo < 0.0; + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + let fm = f(mid); + if (fm < 0.0) == lo_neg { + lo = mid; + } else { + hi = mid; + } + } + return Ok(0.5 * (lo + hi)); + } + Err(CellError::Num) +} + +fn rate(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (np, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (fv, typ, guess) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?, opt_num(a, 5, 0.1)?); + if np <= 0.0 { + return Err(CellError::Num); + } + let f = |r: f64| { + if r.abs() < 1e-12 { + pv + pmt * np + fv + } else { + let p = (1.0 + r).powf(np); + pv * p + pmt * (1.0 + r * typ) * (p - 1.0) / r + fv + } + }; + num_val(solve_rate(f, guess)?) +} + +fn npv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let rate = num(a, 0)?; + if rate == -1.0 { + return Err(CellError::Div0); + } + let vals = numbers(a.get(1..).unwrap_or(&[]))?; + let mut sum = 0.0; + let mut d = 1.0; + for v in vals { + d *= 1.0 + rate; + sum += v / d; + } + num_val(sum) +} + +/// Values and dates for XNPV/XIRR: both must be numeric and of equal length. +fn dated_flows(values: &Value, dates: &Value) -> R<(Vec, Vec)> { + let va = as_array(values); + let da = as_array(dates); + if va.data.len() != da.data.len() { + return Err(CellError::Num); + } + let mut v = Vec::with_capacity(va.data.len()); + let mut d = Vec::with_capacity(da.data.len()); + for (x, y) in va.data.iter().zip(da.data.iter()) { + match x { + Value::Number(n) => v.push(*n), + Value::Error(e) => return Err(*e), + _ => return Err(CellError::Value), + } + match y { + Value::Number(n) => d.push(n.trunc()), + Value::Error(e) => return Err(*e), + Value::Text(_) => d.push(y.to_number()?.trunc()), + _ => return Err(CellError::Value), + } + } + let d0 = *d.first().ok_or(CellError::Num)?; + if d.iter().any(|&x| x < d0 || x < 0.0) { + return Err(CellError::Num); + } + Ok((v, d)) +} + +fn xnpv_calc(rate: f64, v: &[f64], d: &[f64]) -> f64 { + let d0 = d.first().copied().unwrap_or(0.0); + v.iter().zip(d).map(|(x, t)| x / (1.0 + rate).powf((t - d0) / 365.0)).sum() +} + +fn xnpv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let rate = num(a, 0)?; + if rate <= -1.0 { + return Err(CellError::Num); + } + let (v, d) = dated_flows(&a.get(1).ok_or(CellError::Value)?.value, &a.get(2).ok_or(CellError::Value)?.value)?; + num_val(xnpv_calc(rate, &v, &d)) +} + +fn xirr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (v, d) = dated_flows(&a.first().ok_or(CellError::Value)?.value, &a.get(1).ok_or(CellError::Value)?.value)?; + let guess = opt_num(a, 2, 0.1)?; + if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { + return Err(CellError::Num); + } + num_val(solve_rate(|r| xnpv_calc(r, &v, &d), guess)?) +} + +fn irr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; + let guess = opt_num(a, 1, 0.1)?; + if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { + return Err(CellError::Num); + } + let f = |r: f64| { + let mut s = 0.0; + let mut d = 1.0; + for x in &v { + s += x / d; + d *= 1.0 + r; + } + s + }; + num_val(solve_rate(f, guess)?) +} + +fn mirr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; + let (fr, rr) = (num(a, 1)?, num(a, 2)?); + let n = v.len() as f64; + if v.len() < 2 { + return Err(CellError::Div0); + } + let (mut pos, mut neg) = (0.0, 0.0); + for (i, x) in v.iter().enumerate() { + if *x > 0.0 { + pos += x / (1.0 + rr).powi(i as i32); + } else if *x < 0.0 { + neg += x / (1.0 + fr).powi(i as i32); + } + } + if pos == 0.0 || neg == 0.0 { + return Err(CellError::Div0); + } + let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0; + num_val(r) +} + +fn fvschedule(a: &[Arg], _c: &mut dyn Ctx) -> R { + let mut p = num(a, 0)?; + for v in flatten(a.get(1..2).unwrap_or(&[])) { + let r = match v { + Value::Number(n) => n, + Value::Empty => 0.0, + Value::Error(e) => return Err(e), + _ => return Err(CellError::Value), + }; + p *= 1.0 + r; + } + num_val(p) +} + +fn effect(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (nom, n) = (num(a, 0)?, num(a, 1)?.trunc()); + if nom <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + num_val((1.0 + nom / n).powf(n) - 1.0) +} + +fn nominal(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (eff, n) = (num(a, 0)?, num(a, 1)?.trunc()); + if eff <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + num_val(n * ((1.0 + eff).powf(1.0 / n) - 1.0)) +} + +fn ispmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + if np == 0.0 { + return Err(CellError::Div0); + } + num_val(pv * rate * (per / np - 1.0)) +} + +fn pduration(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if rate <= 0.0 || pv <= 0.0 || fv <= 0.0 { + return Err(CellError::Num); + } + num_val((fv.ln() - pv.ln()) / (1.0 + rate).ln()) +} + +fn rri(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (np, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if np <= 0.0 || pv == 0.0 { + return Err(CellError::Num); + } + num_val((fv / pv).powf(1.0 / np) - 1.0) +} + +fn dollar_digits(fraction: f64) -> R { + if fraction < 0.0 { + return Err(CellError::Num); + } + if fraction < 1.0 { + return Err(CellError::Div0); + } + Ok(10f64.powf(fraction.log10().ceil())) +} + +fn dollarde(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); + let p = dollar_digits(f)?; + let int = x.trunc(); + num_val(int + (x - int) * p / f) +} + +fn dollarfr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); + let p = dollar_digits(f)?; + let int = x.trunc(); + num_val(int + (x - int) * f / p) +} + +// --------------------------------------------------------------------------------------------- +// Depreciation +// --------------------------------------------------------------------------------------------- + +fn sln(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if life == 0.0 { + return Err(CellError::Div0); + } + num_val((cost - salvage) / life) +} + +fn syd(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, per) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + if life <= 0.0 || per <= 0.0 || per > life || salvage < 0.0 { + return Err(CellError::Num); + } + num_val((cost - salvage) * (life - per + 1.0) * 2.0 / (life * (life + 1.0))) +} + +fn db(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?.trunc()); + let month = opt_num(a, 4, 12.0)?.trunc(); + if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || !(1.0..=12.0).contains(&month) { + return Err(CellError::Num); + } + let max_period = if month == 12.0 { life.trunc() } else { life.trunc() + 1.0 }; + if period > max_period.max(1.0) || period > 1e7 { + return Err(CellError::Num); + } + if cost == 0.0 { + return num_val(0.0); + } + let rate = crate::util::round_half_away(1.0 - (salvage / cost).powf(1.0 / life), 3); + let mut total = 0.0; + let mut dep = 0.0; + let mut p = 1.0; + while p <= period { + dep = if p == 1.0 { + cost * rate * month / 12.0 + } else if p == life.trunc() + 1.0 { + (cost - total) * rate * (12.0 - month) / 12.0 + } else { + (cost - total) * rate + }; + total += dep; + p += 1.0; + } + num_val(dep) +} + +fn ddb(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + let factor = opt_num(a, 4, 2.0)?; + if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || factor <= 0.0 || period > life { + return Err(CellError::Num); + } + let mut rate = factor / life; + let old; + if rate >= 1.0 { + rate = 1.0; + old = if period == 1.0 { cost } else { 0.0 }; + } else { + old = cost * (1.0 - rate).powf(period - 1.0); + } + let new = cost * (1.0 - rate).powf(period); + let dep = if new < salvage { old - salvage } else { old - new }; + num_val(dep.max(0.0)) +} + +/// Per-period declining-balance depreciation with optional switch to straight line. +fn vdb_schedule(cost: f64, salvage: f64, life: f64, factor: f64, no_switch: bool, periods: usize) -> Vec { + let mut out = Vec::with_capacity(periods); + let mut book = cost; + let mut sl: Option = None; + for i in 0..periods { + let dep = if let Some(s) = sl { + s.min((book - salvage).max(0.0)) + } else { + let ddb = (book * factor / life).min((book - salvage).max(0.0)); + let remaining = life - i as f64; + let slv = if remaining > 0.0 { (book - salvage) / remaining.max(1.0) } else { 0.0 }; + if !no_switch && slv > ddb { + sl = Some(slv); + slv.min((book - salvage).max(0.0)) + } else { + ddb + } + }; + out.push(dep); + book -= dep; + } + out +} + +fn vdb(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, start, end) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); + let factor = opt_num(a, 5, 2.0)?; + let no_switch = opt_bool(a, 6, false)?; + if cost < 0.0 || salvage < 0.0 || life <= 0.0 || start < 0.0 || end < start || end > life || factor <= 0.0 { + return Err(CellError::Num); + } + let periods = end.ceil(); + if periods > 1e7 { + return Err(CellError::Num); + } + let sched = vdb_schedule(cost, salvage, life, factor, no_switch, periods as usize); + let mut total = 0.0; + for (i, d) in sched.iter().enumerate() { + let (p0, p1) = (i as f64, i as f64 + 1.0); + let overlap = (p1.min(end) - p0.max(start)).max(0.0); + total += d * overlap; + } + num_val(total) +} + +fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); + let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); + let _ = c; + if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased { + return Err(CellError::Num); + } + let full = cost * rate; + let limit = cost - salvage; + let dep0 = (full * year_frac(purchased, first, basis)?).min(limit); + if period == 0.0 { + return num_val(dep0); + } + let remaining = limit - dep0 - (period - 1.0) * full; + num_val(full.min(remaining.max(0.0))) +} + +fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); + let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); + let _ = c; + if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased || period > 1e6 { + return Err(CellError::Num); + } + let life = 1.0 / rate; + let coef = if life < 3.0 { + 1.0 + } else if life < 5.0 { + 1.5 + } else if life <= 6.0 { + 2.0 + } else { + 2.5 + }; + let r = rate * coef; + let mut book = cost; + let mut dep = (year_frac(purchased, first, basis)? * r * cost).round(); + if period == 0.0 { + return num_val(dep); + } + book -= dep; + let mut rest = book - salvage; + let mut n = 1.0; + while n <= period { + dep = (r * book).round(); + rest -= dep; + if rest < 0.0 { + // The two final periods split what remains; afterwards nothing is left. + let left = period - n; + return num_val(if left <= 1.0 { (book * 0.5).round() } else { 0.0 }); + } + book -= dep; + n += 1.0; + } + num_val(dep) +} + +// --------------------------------------------------------------------------------------------- +// Dates and day counts +// --------------------------------------------------------------------------------------------- + +fn date(a: &[Arg], i: usize) -> R { + let d = num(a, i)?.trunc(); + if !(0.0..=2_958_465.0).contains(&d) { + return Err(CellError::Num); + } + Ok(d) +} + +fn ymd(s: f64) -> R<(i32, u32, u32)> { + let dt = datetime_from_serial(DateSystem::D1900, s.trunc()).ok_or(CellError::Num)?; + Ok((dt.year, dt.month, dt.day.max(1))) +} + +fn serial(y: i32, m: u32, d: u32) -> R { + serial_from_ymd(DateSystem::D1900, y as i64, m as i64, d as i64).ok_or(CellError::Num) +} + +fn basis_arg(a: &[Arg], i: usize) -> R { + let b = opt_num(a, i, 0.0)?.trunc(); + if !(0.0..=4.0).contains(&b) { + return Err(CellError::Num); + } + Ok(b as u32) +} + +fn freq_arg(a: &[Arg], i: usize) -> R { + let f = num(a, i)?.trunc(); + if f != 1.0 && f != 2.0 && f != 4.0 { + return Err(CellError::Num); + } + Ok(f) +} + +fn is_leap(y: i32) -> bool { + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +} + +/// 30/360 day count (US/NASD when `us`, European otherwise). +fn days360(s1: f64, s2: f64, us: bool) -> R { + let (y1, m1, mut d1) = ymd(s1)?; + let (y2, m2, mut d2) = ymd(s2)?; + if us { + let feb_end1 = m1 == 2 && d1 >= days_in_month(y1, 2); + let feb_end2 = m2 == 2 && d2 >= days_in_month(y2, 2); + if feb_end1 && feb_end2 { + d2 = 30; + } + if feb_end1 { + d1 = 30; + } + if d2 == 31 && d1 >= 30 { + d2 = 30; + } + if d1 == 31 { + d1 = 30; + } + } else { + d1 = d1.min(30); + d2 = d2.min(30); + } + Ok((y2 - y1) as f64 * 360.0 + (m2 as f64 - m1 as f64) * 30.0 + (d2 as f64 - d1 as f64)) +} + +/// Days between two dates under a basis. +fn day_count(s1: f64, s2: f64, basis: u32) -> R { + match basis { + 0 => days360(s1, s2, true), + 4 => days360(s1, s2, false), + _ => Ok(s2 - s1), + } +} + +/// Year fraction between two dates under a basis (YEARFRAC semantics). +fn year_frac(s1: f64, s2: f64, basis: u32) -> R { + let (s1, s2) = if s1 <= s2 { (s1, s2) } else { (s2, s1) }; + match basis { + 0 => Ok(days360(s1, s2, true)? / 360.0), + 2 => Ok((s2 - s1) / 360.0), + 3 => Ok((s2 - s1) / 365.0), + 4 => Ok(days360(s1, s2, false)? / 360.0), + _ => { + let (y1, m1, d1) = ymd(s1)?; + let (y2, m2, d2) = ymd(s2)?; + let days = s2 - s1; + if y1 == y2 { + return Ok(days / if is_leap(y1) { 366.0 } else { 365.0 }); + } + let within_year = y2 == y1 + 1 && (m2 < m1 || (m2 == m1 && d2 <= d1)); + if within_year { + let feb29_1 = is_leap(y1) && (m1 < 2 || (m1 == 2 && d1 <= 29)); + let feb29_2 = is_leap(y2) && (m2 > 2 || (m2 == 2 && d2 == 29)); + let denom = if feb29_1 || feb29_2 { 366.0 } else { 365.0 }; + return Ok(days / denom); + } + let start = serial(y1, 1, 1)?; + let end = serial(y2 + 1, 1, 1).unwrap_or(2_958_466.0); + let avg = (end - start) / (y2 - y1 + 1) as f64; + Ok(days / avg) + } + } +} + +/// Days in a year for discount securities. +fn year_basis(basis: u32, s: f64) -> R { + Ok(match basis { + 0 | 2 | 4 => 360.0, + 3 => 365.0, + _ => { + if is_leap(ymd(s)?.0) { + 366.0 + } else { + 365.0 + } + } + }) +} + +/// Date `months` months before/after an anchor date, keeping end-of-month alignment. +fn shift_months(anchor: (i32, u32, u32), months: i64, eom: bool) -> R { + let (y, m, d) = anchor; + let total = y as i64 * 12 + (m as i64 - 1) + months; + let ny = total.div_euclid(12); + let nm = (total.rem_euclid(12) + 1) as u32; + if !(1900..=9999).contains(&ny) { + return Err(CellError::Num); + } + let dim = days_in_month(ny as i32, nm); + let nd = if eom { dim } else { d.min(dim) }; + serial(ny as i32, nm, nd) +} + +struct Coupon { + pcd: f64, + ncd: f64, + num: f64, +} + +fn coupons(settle: f64, mat: f64, freq: f64) -> R { + if settle >= mat { + return Err(CellError::Num); + } + let anchor = ymd(mat)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + // Estimate the count from the span, then adjust. + let mut k = (((mat - settle) / 365.25 * freq).floor() as i64).max(1); + while k > 1 && shift_months(anchor, -(k - 1) * step, eom)? <= settle { + k -= 1; + } + let mut guard = 0; + while shift_months(anchor, -k * step, eom)? > settle { + k += 1; + guard += 1; + if guard > 100_000 { + return Err(CellError::Num); + } + } + Ok(Coupon { pcd: shift_months(anchor, -k * step, eom)?, ncd: shift_months(anchor, -(k - 1) * step, eom)?, num: k as f64 }) +} + +fn coup_days(c: &Coupon, freq: f64, basis: u32) -> f64 { + match basis { + 1 => c.ncd - c.pcd, + 3 => 365.0 / freq, + _ => 360.0 / freq, + } +} + +fn coup_daybs(c: &Coupon, settle: f64, basis: u32) -> R { + day_count(c.pcd, settle, basis) +} + +fn coup_daysnc(c: &Coupon, settle: f64, freq: f64, basis: u32) -> R { + match basis { + 0 | 4 => Ok(coup_days(c, freq, basis) - coup_daybs(c, settle, basis)?), + _ => Ok(c.ncd - settle), + } +} + +/// settlement, maturity, frequency, basis at positions 0..=3 for the COUP* functions. +fn coup_args(a: &[Arg]) -> R<(f64, f64, f64, u32, Coupon)> { + let (s, m, f, b) = (date(a, 0)?, date(a, 1)?, freq_arg(a, 2)?, basis_arg(a, 3)?); + let c = coupons(s, m, f)?; + Ok((s, m, f, b, c)) +} + +fn coupdaybs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (s, _, _, b, c) = coup_args(a)?; + num_val(coup_daybs(&c, s, b)?) +} + +fn coupdays(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (_, _, f, b, c) = coup_args(a)?; + num_val(coup_days(&c, f, b)) +} + +fn coupdaysnc(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (s, _, f, b, c) = coup_args(a)?; + num_val(coup_daysnc(&c, s, f, b)?) +} + +fn coupncd(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(coup_args(a)?.4.ncd) +} + +fn couppcd(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(coup_args(a)?.4.pcd) +} + +fn coupnum(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(coup_args(a)?.4.num) +} + +// --------------------------------------------------------------------------------------------- +// Securities +// --------------------------------------------------------------------------------------------- + +fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (issue, first, settle, rate) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); + let par = if has(a, 4) { num(a, 4)? } else { 1000.0 }; + let freq = freq_arg(a, 5)?; + let basis = basis_arg(a, 6)?; + let calc_method = opt_bool(a, 7, true)?; + if issue >= settle || rate <= 0.0 || par <= 0.0 { + return Err(CellError::Num); + } + let anchor = ymd(first)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + let cd = |j: i64| shift_months(anchor, j * step, eom); + // Coupon index of the period containing a date: cd(j) <= date < cd(j+1). + let index_of = |d: f64| -> R { + let mut j = (((d - first) / 365.25 * freq).floor() as i64).clamp(-200_000, 200_000); + let mut guard = 0; + while cd(j)? > d { + j -= 1; + guard += 1; + if guard > 400_000 { + return Err(CellError::Num); + } + } + while cd(j + 1)? <= d { + j += 1; + guard += 1; + if guard > 400_000 { + return Err(CellError::Num); + } + } + Ok(j) + }; + let start = if calc_method || settle <= first { issue } else { cd(index_of(settle)?)? }; + let mut j = index_of(start)?; + let mut sum = 0.0; + let mut guard = 0; + loop { + let (p0, p1) = (cd(j)?, cd(j + 1)?); + if p0 >= settle { + break; + } + let from = p0.max(start); + let to = p1.min(settle); + if to > from { + let nl = match basis { + 1 => p1 - p0, + 3 => 365.0 / freq, + _ => 360.0 / freq, + }; + sum += day_count(from, to, basis)? / nl; + } + j += 1; + guard += 1; + if guard > 100_000 { + return Err(CellError::Num); + } + } + num_val(par * rate / freq * sum) +} + +fn accrintm(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (issue, settle, rate) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); + let par = if has(a, 3) { num(a, 3)? } else { 1000.0 }; + let basis = basis_arg(a, 4)?; + if issue >= settle || rate <= 0.0 || par <= 0.0 { + return Err(CellError::Num); + } + num_val(par * rate * year_frac(issue, settle, basis)?) +} + +/// settlement, maturity, value, value, [basis] for discount securities. +fn disc_args(a: &[Arg]) -> R<(f64, f64, f64, f64, u32)> { + let (s, m, x, y, b) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, basis_arg(a, 4)?); + if s >= m || x <= 0.0 || y <= 0.0 { + return Err(CellError::Num); + } + Ok((s, m, x, y, b)) +} + +fn disc_frac(s: f64, m: f64, b: u32) -> R { + Ok(day_count(s, m, b)? / year_basis(b, s)?) +} + +fn disc(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (s, m, pr, red, b) = disc_args(a)?; + num_val((1.0 - pr / red) / disc_frac(s, m, b)?) +} + +fn pricedisc(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (s, m, d, red, b) = disc_args(a)?; + num_val(red - d * red * disc_frac(s, m, b)?) +} + +fn yielddisc(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (s, m, pr, red, b) = disc_args(a)?; + num_val((red / pr - 1.0) / disc_frac(s, m, b)?) +} + +fn intrate(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (s, m, inv, red, b) = disc_args(a)?; + num_val((red - inv) / inv / disc_frac(s, m, b)?) +} + +fn received(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (s, m, inv, d, b) = disc_args(a)?; + let denom = 1.0 - d * disc_frac(s, m, b)?; + if denom <= 0.0 { + return Err(CellError::Num); + } + num_val(inv / denom) +} + +fn tbill_args(a: &[Arg]) -> R<(f64, f64)> { + let (s, m, x) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); + if s >= m || x <= 0.0 { + return Err(CellError::Num); + } + // At most one year after settlement. + let (y, mo, d) = ymd(s)?; + let limit = shift_months((y, mo, d), 12, false)?; + if m > limit { + return Err(CellError::Num); + } + Ok((m - s, x)) +} + +fn tbillprice(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (dsm, d) = tbill_args(a)?; + let p = 100.0 * (1.0 - d * dsm / 360.0); + if p <= 0.0 { + return Err(CellError::Num); + } + num_val(p) +} + +fn tbillyield(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (dsm, pr) = tbill_args(a)?; + num_val((100.0 - pr) / pr * 360.0 / dsm) +} + +fn tbilleq(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (dsm, d) = tbill_args(a)?; + if dsm <= 182.0 { + return num_val(365.0 * d / (360.0 - d * dsm)); + } + let price = 100.0 * (1.0 - d * dsm / 360.0); + if price <= 0.0 { + return Err(CellError::Num); + } + let t = dsm / 365.0; + let disc = t * t - (2.0 * t - 1.0) * (1.0 - 100.0 / price); + if disc < 0.0 { + return Err(CellError::Num); + } + num_val((-t + disc.sqrt()) / (t - 0.5)) +} + +/// settlement, maturity, issue, rate, value, [basis]. +fn mat_terms(a: &[Arg]) -> R<(f64, f64, f64, f64, f64)> { + let (s, m, issue, rate, x, b) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?, num(a, 4)?, basis_arg(a, 5)?); + if s >= m || issue >= s || rate < 0.0 || x < 0.0 { + return Err(CellError::Num); + } + let dim = year_frac(issue, m, b)?; + let dsm = year_frac(s, m, b)?; + let ai = year_frac(issue, s, b)?; + Ok((dim, dsm, ai, rate, x)) +} + +fn pricemat(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (dim, dsm, ai, rate, yld) = mat_terms(a)?; + num_val((100.0 + dim * rate * 100.0) / (1.0 + dsm * yld) - ai * rate * 100.0) +} + +fn yieldmat(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (dim, dsm, ai, rate, pr) = mat_terms(a)?; + if pr <= 0.0 || dsm == 0.0 { + return Err(CellError::Num); + } + let base = pr / 100.0 + ai * rate; + num_val(((1.0 + dim * rate) - base) / base / dsm) +} + +struct Bond { + n: f64, + e: f64, + a: f64, + dsc: f64, + freq: f64, +} + +fn bond(settle: f64, mat: f64, freq: f64, basis: u32) -> R { + let c = coupons(settle, mat, freq)?; + let e = coup_days(&c, freq, basis); + let a = coup_daybs(&c, settle, basis)?; + let dsc = coup_daysnc(&c, settle, freq, basis)?; + if e <= 0.0 { + return Err(CellError::Num); + } + Ok(Bond { n: c.num, e, a, dsc, freq }) +} + +fn bond_price(b: &Bond, rate: f64, yld: f64, red: f64) -> f64 { + let f = b.freq; + let coupon = 100.0 * rate / f; + let accrued = coupon * b.a / b.e; + if b.n == 1.0 { + return (red + coupon) / (1.0 + b.dsc / b.e * yld / f) - accrued; + } + let base = 1.0 + yld / f; + let frac = b.dsc / b.e; + let mut sum = red / base.powf(b.n - 1.0 + frac); + let mut k = 1.0; + while k <= b.n { + sum += coupon / base.powf(k - 1.0 + frac); + k += 1.0; + } + sum - accrued +} + +fn price(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (s, m, rate, yld, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); + let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); + if rate < 0.0 || yld < 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + let b = bond(s, m, f, basis)?; + num_val(bond_price(&b, rate, yld, red)) +} + +fn yield_(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (s, m, rate, pr, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); + let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); + if rate < 0.0 || pr <= 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + let b = bond(s, m, f, basis)?; + if b.n == 1.0 { + let base = pr / 100.0 + b.a / b.e * rate / f; + return num_val(((red / 100.0 + rate / f) - base) / base * f * b.e / b.dsc); + } + // Price is decreasing in yield: bisect on [0, hi]. + let g = |y: f64| bond_price(&b, rate, y, red) - pr; + let (mut lo, mut hi) = (-0.999_999 * f, 1.0); + let mut guard = 0; + while g(hi) > 0.0 && guard < 60 { + hi *= 2.0; + guard += 1; + } + if !(g(lo) >= 0.0 && g(hi) <= 0.0) { + return Err(CellError::Num); + } + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + if g(mid) > 0.0 { + lo = mid; + } else { + hi = mid; + } + } + num_val(0.5 * (lo + hi)) +} + +fn duration_calc(a: &[Arg]) -> R<(f64, f64, f64)> { + let (s, m, coupon, yld) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?); + let (f, basis) = (freq_arg(a, 4)?, basis_arg(a, 5)?); + if coupon < 0.0 || yld < 0.0 { + return Err(CellError::Num); + } + let b = bond(s, m, f, basis)?; + let c = coupon * 100.0 / f; + let y = 1.0 + yld / f; + let frac = b.dsc / b.e; + let (mut wsum, mut psum) = (0.0, 0.0); + let mut k = 1.0; + while k <= b.n { + let t = k - 1.0 + frac; + let cf = if k == b.n { c + 100.0 } else { c }; + let pv = cf / y.powf(t); + wsum += t * pv; + psum += pv; + k += 1.0; + } + if psum == 0.0 { + return Err(CellError::Num); + } + Ok((wsum / psum / f, yld, f)) +} + +fn duration(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(duration_calc(a)?.0) +} + +fn mduration(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (d, y, f) = duration_calc(a)?; + num_val(d / (1.0 + y / f)) +} + +pub(crate) fn specs() -> Vec { + vec![ + f!( + "PMT", + 3, + 5, + Financial, + S, + "PMT(rate, nper, pv, [fv], [type])", + "Periodic payment for a loan or annuity with constant payments and rate.", + pmt + ), + f!("IPMT", 4, 6, Financial, S, "IPMT(rate, per, nper, pv, [fv], [type])", "Interest part of a given period's payment.", ipmt), + f!("PPMT", 4, 6, Financial, S, "PPMT(rate, per, nper, pv, [fv], [type])", "Principal part of a given period's payment.", ppmt), + f!("PV", 3, 5, Financial, S, "PV(rate, nper, pmt, [fv], [type])", "Present value of a series of equal future payments.", pv), + f!("FV", 3, 5, Financial, S, "FV(rate, nper, pmt, [pv], [type])", "Future value of an investment with constant payments and rate.", fv), + f!("NPER", 3, 5, Financial, S, "NPER(rate, pmt, pv, [fv], [type])", "Number of periods needed to pay off or accumulate an amount.", nper), + f!( + "RATE", + 3, + 6, + Financial, + S, + "RATE(nper, pmt, pv, [fv], [type], [guess])", + "Interest rate per period of an annuity, found iteratively.", + rate + ), + f!( + "CUMIPMT", + 6, + 6, + Financial, + S, + "CUMIPMT(rate, nper, pv, start_period, end_period, type)", + "Total interest paid between two periods.", + cumipmt + ), + f!( + "CUMPRINC", + 6, + 6, + Financial, + S, + "CUMPRINC(rate, nper, pv, start_period, end_period, type)", + "Total principal repaid between two periods.", + cumprinc + ), + f!( + "NPV", + 2, + VAR, + Financial, + &[true, false], + "NPV(rate, value1, [value2], ...)", + "Net present value of periodic cash flows at a discount rate.", + npv + ), + f!("XNPV", 3, 3, Financial, &[true, false, false], "XNPV(rate, values, dates)", "Net present value of cash flows on specific dates.", xnpv), + f!("IRR", 1, 2, Financial, &[false, true], "IRR(values, [guess])", "Internal rate of return of periodic cash flows.", irr), + f!( + "XIRR", + 2, + 3, + Financial, + &[false, false, true], + "XIRR(values, dates, [guess])", + "Internal rate of return of cash flows on specific dates.", + xirr + ), + f!( + "MIRR", + 3, + 3, + Financial, + &[false, true, true], + "MIRR(values, finance_rate, reinvest_rate)", + "Modified internal rate of return using separate borrow and reinvest rates.", + mirr + ), + f!( + "FVSCHEDULE", + 2, + 2, + Financial, + &[true, false], + "FVSCHEDULE(principal, schedule)", + "Future value of a principal after a series of compound rates.", + fvschedule + ), + f!("EFFECT", 2, 2, Financial, S, "EFFECT(nominal_rate, npery)", "Effective annual rate from a nominal rate and compounding periods.", effect), + f!( + "NOMINAL", + 2, + 2, + Financial, + S, + "NOMINAL(effect_rate, npery)", + "Nominal annual rate from an effective rate and compounding periods.", + nominal + ), + f!("ISPMT", 4, 4, Financial, S, "ISPMT(rate, per, nper, pv)", "Interest paid in a period of a loan with even principal payments.", ispmt), + f!("PDURATION", 3, 3, Financial, S, "PDURATION(rate, pv, fv)", "Periods needed for an investment to grow to a target value.", pduration), + f!("RRI", 3, 3, Financial, S, "RRI(nper, pv, fv)", "Equivalent interest rate for the growth of an investment.", rri), + f!( + "DOLLARDE", + 2, + 2, + Financial, + S, + "DOLLARDE(fractional_dollar, fraction)", + "Converts a fractional price notation to a decimal number.", + dollarde + ), + f!("DOLLARFR", 2, 2, Financial, S, "DOLLARFR(decimal_dollar, fraction)", "Converts a decimal price to fractional notation.", dollarfr), + f!("SLN", 3, 3, Financial, S, "SLN(cost, salvage, life)", "Straight-line depreciation for one period.", sln), + f!("SYD", 4, 4, Financial, S, "SYD(cost, salvage, life, per)", "Sum-of-years'-digits depreciation for a period.", syd), + f!("DB", 4, 5, Financial, S, "DB(cost, salvage, life, period, [month])", "Fixed-declining-balance depreciation for a period.", db), + f!( + "DDB", + 4, + 5, + Financial, + S, + "DDB(cost, salvage, life, period, [factor])", + "Double-declining-balance (or other factor) depreciation for a period.", + ddb + ), + f!( + "VDB", + 5, + 7, + Financial, + S, + "VDB(cost, salvage, life, start_period, end_period, [factor], [no_switch])", + "Declining-balance depreciation over any span of periods, switching to straight line.", + vdb + ), + f!( + "AMORLINC", + 6, + 7, + Financial, + S, + "AMORLINC(cost, date_purchased, first_period, salvage, period, rate, [basis])", + "French linear depreciation prorated for the first period.", + amorlinc + ), + f!( + "AMORDEGRC", + 6, + 7, + Financial, + S, + "AMORDEGRC(cost, date_purchased, first_period, salvage, period, rate, [basis])", + "French degressive depreciation with a life-based coefficient.", + amordegrc + ), + f!( + "ACCRINT", + 6, + 8, + Financial, + S, + "ACCRINT(issue, first_interest, settlement, rate, par, frequency, [basis], [calc_method])", + "Accrued interest of a security paying periodic interest.", + accrint + ), + f!( + "ACCRINTM", + 3, + 5, + Financial, + S, + "ACCRINTM(issue, settlement, rate, [par], [basis])", + "Accrued interest of a security paying interest at maturity.", + accrintm + ), + f!( + "COUPDAYBS", + 3, + 4, + Financial, + S, + "COUPDAYBS(settlement, maturity, frequency, [basis])", + "Days from the start of the coupon period to settlement.", + coupdaybs + ), + f!( + "COUPDAYS", + 3, + 4, + Financial, + S, + "COUPDAYS(settlement, maturity, frequency, [basis])", + "Days in the coupon period that contains settlement.", + coupdays + ), + f!( + "COUPDAYSNC", + 3, + 4, + Financial, + S, + "COUPDAYSNC(settlement, maturity, frequency, [basis])", + "Days from settlement to the next coupon date.", + coupdaysnc + ), + f!("COUPNCD", 3, 4, Financial, S, "COUPNCD(settlement, maturity, frequency, [basis])", "Next coupon date after settlement.", coupncd), + f!( + "COUPPCD", + 3, + 4, + Financial, + S, + "COUPPCD(settlement, maturity, frequency, [basis])", + "Previous coupon date on or before settlement.", + couppcd + ), + f!( + "COUPNUM", + 3, + 4, + Financial, + S, + "COUPNUM(settlement, maturity, frequency, [basis])", + "Number of coupons payable between settlement and maturity.", + coupnum + ), + f!("DISC", 4, 5, Financial, S, "DISC(settlement, maturity, pr, redemption, [basis])", "Discount rate of a security.", disc), + f!( + "PRICEDISC", + 4, + 5, + Financial, + S, + "PRICEDISC(settlement, maturity, discount, redemption, [basis])", + "Price per 100 face value of a discounted security.", + pricedisc + ), + f!( + "YIELDDISC", + 4, + 5, + Financial, + S, + "YIELDDISC(settlement, maturity, pr, redemption, [basis])", + "Annual yield of a discounted security.", + yielddisc + ), + f!( + "INTRATE", + 4, + 5, + Financial, + S, + "INTRATE(settlement, maturity, investment, redemption, [basis])", + "Interest rate of a fully invested security.", + intrate + ), + f!( + "RECEIVED", + 4, + 5, + Financial, + S, + "RECEIVED(settlement, maturity, investment, discount, [basis])", + "Amount received at maturity for a fully invested security.", + received + ), + f!( + "TBILLPRICE", + 3, + 3, + Financial, + S, + "TBILLPRICE(settlement, maturity, discount)", + "Price per 100 face value of a Treasury bill.", + tbillprice + ), + f!("TBILLYIELD", 3, 3, Financial, S, "TBILLYIELD(settlement, maturity, pr)", "Yield of a Treasury bill.", tbillyield), + f!("TBILLEQ", 3, 3, Financial, S, "TBILLEQ(settlement, maturity, discount)", "Bond-equivalent yield of a Treasury bill.", tbilleq), + f!( + "PRICEMAT", + 5, + 6, + Financial, + S, + "PRICEMAT(settlement, maturity, issue, rate, yld, [basis])", + "Price per 100 face value of a security paying interest at maturity.", + pricemat + ), + f!( + "YIELDMAT", + 5, + 6, + Financial, + S, + "YIELDMAT(settlement, maturity, issue, rate, pr, [basis])", + "Annual yield of a security paying interest at maturity.", + yieldmat + ), + f!( + "PRICE", + 6, + 7, + Financial, + S, + "PRICE(settlement, maturity, rate, yld, redemption, frequency, [basis])", + "Price per 100 face value of a security paying periodic interest.", + price + ), + f!( + "YIELD", + 6, + 7, + Financial, + S, + "YIELD(settlement, maturity, rate, pr, redemption, frequency, [basis])", + "Yield of a security paying periodic interest.", + yield_ + ), + f!( + "DURATION", + 5, + 6, + Financial, + S, + "DURATION(settlement, maturity, coupon, yld, frequency, [basis])", + "Macaulay duration of a security with periodic interest.", + duration + ), + f!( + "MDURATION", + 5, + 6, + Financial, + S, + "MDURATION(settlement, maturity, coupon, yld, frequency, [basis])", + "Modified duration of a security with periodic interest.", + mduration + ), + ] +} + +#[cfg(test)] +mod tests { + use crate::Arg; + use crate::util::testutil::*; + use sheetcraft_core::{CellError, DateSystem, Value, date::serial_from_ymd}; + + fn d(y: i64, m: i64, dd: i64) -> Arg { + n(serial_from_ymd(DateSystem::D1900, y, m, dd).unwrap()) + } + + #[test] + fn tvm() { + close_tol(ev("PMT", vec![n(0.08 / 12.0), n(10.0), n(10000.0)]), -1037.032089, 1e-9); + close_tol(ev("PMT", vec![n(0.06 / 12.0), n(216.0), n(0.0), n(50000.0)]), -129.0811609, 1e-9); + close(ev("PMT", vec![n(0.0), n(10.0), n(1000.0)]), -100.0); + is_err(ev("PMT", vec![n(0.1), n(0.0), n(1000.0)]), CellError::Num); + close_tol(ev("FV", vec![n(0.06 / 12.0), n(10.0), n(-200.0), n(-500.0), n(1.0)]), 2581.403374, 1e-9); + close_tol(ev("FV", vec![n(0.01), n(12.0), n(-1000.0)]), 12682.50301, 1e-9); + close(ev("FV", vec![n(0.0), n(10.0), n(-100.0)]), 1000.0); + close_tol(ev("PV", vec![n(0.08 / 12.0), n(240.0), n(500.0)]), -59777.14585, 1e-9); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0), n(1.0)]), 59.6738657, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0)]), 60.0821229, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0)]), -9.57859404, 1e-8); + close_tol(ev("IPMT", vec![n(0.1 / 12.0), n(1.0), n(36.0), n(8000.0)]), -66.66666667, 1e-9); + close_tol(ev("IPMT", vec![n(0.1), n(3.0), n(3.0), n(8000.0)]), -292.4471299, 1e-9); + is_err(ev("IPMT", vec![n(0.1), n(4.0), n(3.0), n(8000.0)]), CellError::Num); + close_tol(ev("PPMT", vec![n(0.1 / 12.0), n(1.0), n(24.0), n(2000.0)]), -75.62318601, 1e-9); + close_tol(ev("PPMT", vec![n(0.08), n(10.0), n(10.0), n(200000.0)]), -27598.05346, 1e-9); + close_tol(ev("RATE", vec![n(48.0), n(-200.0), n(8000.0)]), 0.0077014725, 1e-7); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -11135.23213, 1e-9); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -937.5, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -934.1071234, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -68.27827118, 1e-9); + is_err(ev("CUMIPMT", vec![n(0.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), CellError::Num); + is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); + } + + #[test] + fn cash_flows() { + close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9); + close_tol(ev("NPV", vec![n(0.08), rf(col(&[8000.0, 9200.0, 10000.0, 12000.0, 14500.0]))]), 41922.06155, 1e-9); + is_err(ev("NPV", vec![n(-1.0), n(1.0)]), CellError::Div0); + let flows = col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0]); + close_tol(ev("IRR", vec![rf(flows)]), -0.021244848, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0, 26000.0]))]), 0.086630948, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0])), n(-0.1)]), -0.44350694, 1e-7); + is_err(ev("IRR", vec![rf(col(&[1.0, 2.0]))]), CellError::Num); + let vals = col(&[-10000.0, 2750.0, 4250.0, 3250.0, 2750.0]); + let dates = col(&[39448.0, 39508.0, 39751.0, 39859.0, 39904.0]); + close_tol(ev("XNPV", vec![n(0.09), rf(vals.clone()), rf(dates.clone())]), 2086.647602, 1e-9); + close_tol(ev("XIRR", vec![rf(vals.clone()), rf(dates)]), 0.373362535, 1e-7); + is_err(ev("XNPV", vec![n(0.09), rf(vals), rf(col(&[1.0, 2.0]))]), CellError::Num); + let m = col(&[-120000.0, 39000.0, 30000.0, 21000.0, 37000.0, 46000.0]); + close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); + close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); + is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); + close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); + close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); + is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); + close_tol(ev("NOMINAL", vec![n(0.053543), n(4.0)]), 0.052500319, 1e-8); + close_tol(ev("ISPMT", vec![n(0.1 / 12.0), n(0.0), n(36.0), n(8000000.0)]), -66666.66667, 1e-9); + is_err(ev("ISPMT", vec![n(0.1), n(1.0), n(0.0), n(1.0)]), CellError::Div0); + close_tol(ev("PDURATION", vec![n(0.025), n(2000.0), n(2200.0)]), 3.859866163, 1e-9); + is_err(ev("PDURATION", vec![n(0.0), n(2000.0), n(2200.0)]), CellError::Num); + close_tol(ev("RRI", vec![n(96.0), n(10000.0), n(11000.0)]), 0.0009933, 1e-4); + close(ev("DOLLARDE", vec![n(1.02), n(16.0)]), 1.125); + close(ev("DOLLARDE", vec![n(1.1), n(32.0)]), 1.3125); + close(ev("DOLLARFR", vec![n(1.125), n(16.0)]), 1.02); + close(ev("DOLLARFR", vec![n(1.125), n(32.0)]), 1.04); + is_err(ev("DOLLARDE", vec![n(1.02), n(0.0)]), CellError::Div0); + is_err(ev("DOLLARDE", vec![n(1.02), n(-1.0)]), CellError::Num); + } + + #[test] + fn depreciation() { + close(ev("SLN", vec![n(30000.0), n(7500.0), n(10.0)]), 2250.0); + is_err(ev("SLN", vec![n(30000.0), n(7500.0), n(0.0)]), CellError::Div0); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(1.0)]), 4090.909091, 1e-9); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(10.0)]), 409.0909091, 1e-9); + let dbv = |p: f64| ev("DB", vec![n(1000000.0), n(100000.0), n(6.0), n(p), n(7.0)]); + close_tol(dbv(1.0), 186083.3333, 1e-9); + close_tol(dbv(2.0), 259639.4167, 1e-9); + close_tol(dbv(3.0), 176814.4428, 1e-9); + close_tol(dbv(7.0), 15845.09847, 1e-9); + is_err(dbv(8.0), CellError::Num); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(3650.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("DDB", vec![n(2400.0), n(300.0), n(120.0), n(1.0), n(2.0)]), 40.0); + close(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(1.0), n(2.0)]), 480.0); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(2.0), n(1.5)]), 306.0, 1e-12); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(10.0)]), 22.1225472, 1e-8); + is_err(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(11.0)]), CellError::Num); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(3650.0), n(0.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(0.0), n(1.0)]), 40.0); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(1.0)]), 480.0); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0)]), 396.3060533, 1e-8); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0), n(1.5)]), 311.8089367, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(0.875), n(1.5)]), 315.0); + // Over the whole life the total depreciation is cost - salvage. + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(10.0)]), 2100.0); + is_err(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(5.0), n(4.0)]), CellError::Num); + close_tol(ev("AMORLINC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 360.0, 1e-9); + close(ev("AMORDEGRC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 776.0); + } + + #[test] + fn coupons() { + let args = |f: &str| ev(f, vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(1.0)]); + close(args("COUPDAYBS"), 71.0); + close(args("COUPDAYS"), 181.0); + close(args("COUPDAYSNC"), 110.0); + close(args("COUPNCD"), 40678.0); + close(args("COUPPCD"), 40497.0); + close(args("COUPNUM"), 2.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(0.0)]), 180.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(4.0), n(3.0)]), 91.25); + close(ev("COUPNUM", vec![d(2007, 1, 25), d(2008, 11, 15), n(2.0), n(1.0)]), 4.0); + is_err(ev("COUPNUM", vec![d(2012, 1, 25), d(2011, 11, 15), n(2.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(3.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(5.0)]), CellError::Num); + // End-of-month maturity keeps month-end coupon dates. + close(ev("COUPPCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2010, 11, 30).unwrap()); + close(ev("COUPNCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2011, 5, 31).unwrap()); + } + + #[test] + fn securities() { + close_tol(ev("DISC", vec![d(2007, 1, 25), d(2007, 6, 15), n(97.975), n(100.0), n(1.0)]), 0.052420213, 1e-8); + close_tol(ev("PRICEDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(0.0525), n(100.0), n(2.0)]), 99.79583333, 1e-9); + close_tol(ev("YIELDDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(99.795), n(100.0), n(2.0)]), 0.052823, 1e-5); + close_tol(ev("INTRATE", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(1014420.0), n(2.0)]), 0.05768, 1e-9); + close_tol(ev("RECEIVED", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(0.0575), n(2.0)]), 1014584.654, 1e-9); + is_err(ev("DISC", vec![d(2008, 2, 15), d(2008, 1, 15), n(97.0), n(100.0)]), CellError::Num); + close_tol(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.09)]), 98.45, 1e-12); + close_tol(ev("TBILLYIELD", vec![d(2008, 3, 31), d(2008, 6, 1), n(98.45)]), 0.091417, 1e-6); + close_tol(ev("TBILLEQ", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.0914)]), 0.094151, 1e-6); + is_err(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2009, 6, 1), n(0.09)]), CellError::Num); + close_tol(ev("ACCRINTM", vec![d(2008, 4, 1), d(2008, 6, 15), n(0.1), n(1000.0), n(3.0)]), 20.54794521, 1e-9); + is_err(ev("ACCRINTM", vec![d(2008, 6, 15), d(2008, 4, 1), n(0.1)]), CellError::Num); + close_tol(ev("ACCRINT", vec![d(2008, 3, 1), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0)]), 16.66666667, 1e-9); + close_tol(ev("ACCRINT", vec![d(2008, 3, 5), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0), b(false)]), 15.55555556, 1e-9); + close_tol(ev("PRICEMAT", vec![d(2008, 2, 15), d(2008, 4, 13), d(2007, 11, 11), n(0.061), n(0.061), n(0.0)]), 99.98449888, 1e-9); + close_tol(ev("YIELDMAT", vec![d(2008, 3, 15), d(2008, 11, 3), d(2007, 11, 8), n(0.0625), n(100.0123), n(0.0)]), 0.060954, 1e-6); + close_tol(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]), 94.63436, 1e-7); + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2016, 11, 15), n(0.0575), n(95.04287), n(100.0), n(2.0), n(0.0)]), 0.065, 1e-6); + is_err(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(-0.01), n(0.065), n(100.0), n(2.0)]), CellError::Num); + close_tol(ev("DURATION", vec![d(2018, 7, 1), d(2048, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 10.9191453, 1e-8); + close_tol(ev("MDURATION", vec![d(2008, 1, 1), d(2016, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 5.73567, 1e-6); + // A one-coupon bond uses the closed form for yield; PRICE and YIELD invert each other. + let p = ev("PRICE", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(0.04), n(100.0), n(2.0)]); + let pv = match p { + Value::Number(x) => x, + _ => panic!(), + }; + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9); + } + + #[test] + fn lifting() { + let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]); + assert_eq!(rows_of(&v), vec![vec![nv(10.0), nv(20.0)]]); + is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); + is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); + } +} diff --git a/crates/functions/src/info.rs b/crates/functions/src/info.rs new file mode 100644 index 00000000..5885676d --- /dev/null +++ b/crates/functions/src/info.rs @@ -0,0 +1,605 @@ +//! Information, logical, web and cube functions. + +use std::cmp::Ordering; + +use sheetcraft_core::{Array, CellError, Value, compare_text}; + +use crate::util::{A, MAX_CELLS, R, S, has, scalar, text}; +use crate::{Arg, Ctx, FnSpec, VAR}; + +// --------------------------------------------------------------------------------------------- +// Information + +fn first(a: &[Arg]) -> Value { + a.first().map(scalar).unwrap_or(Value::Empty) +} + +fn is_blank(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(matches!(first(a), Value::Empty))) +} +fn is_err(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(matches!(first(a), Value::Error(e) if e != CellError::NA))) +} +fn is_error(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(matches!(first(a), Value::Error(_)))) +} +fn is_na(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(matches!(first(a), Value::Error(CellError::NA)))) +} +fn is_number(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(matches!(first(a), Value::Number(_)))) +} +fn is_text(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(matches!(first(a), Value::Text(_)))) +} +fn is_nontext(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(!matches!(first(a), Value::Text(_)))) +} +fn is_logical(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(matches!(first(a), Value::Bool(_)))) +} + +fn parity(a: &[Arg]) -> R { + match first(a) { + Value::Bool(_) => Err(CellError::Value), + v => { + let n = v.to_number()?; + if n.abs() > 9.007_199_254_740_992e15 { + return Err(CellError::Num); + } + Ok(n.trunc().abs() % 2.0) + } + } +} +fn is_even(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(parity(a)? == 0.0)) +} +fn is_odd(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(parity(a)? == 1.0)) +} + +fn type_of(v: &Value) -> f64 { + match v { + Value::Empty | Value::Number(_) => 1.0, + Value::Text(_) => 2.0, + Value::Bool(_) => 4.0, + Value::Error(_) => 16.0, + Value::Array(_) => 64.0, + } +} +fn type_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { + let Some(arg) = a.first() else { return Err(CellError::Value) }; + let t = match &arg.value { + // A single-cell reference reports the type of its content. + Value::Array(arr) if arg.from_ref && arr.rows == 1 && arr.cols == 1 => arr.data.first().map_or(1.0, type_of), + v => type_of(v), + }; + Ok(Value::Number(t)) +} + +fn error_type(a: &[Arg], _c: &mut dyn Ctx) -> R { + match first(a) { + Value::Error(e) => Ok(Value::Number(e.code() as f64)), + _ => Err(CellError::NA), + } +} + +fn na(_a: &[Arg], _c: &mut dyn Ctx) -> R { + Err(CellError::NA) +} + +fn n_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { + match first(a) { + Value::Number(n) => Ok(Value::Number(n)), + Value::Bool(b) => Ok(Value::Number(if b { 1.0 } else { 0.0 })), + Value::Error(e) => Err(e), + _ => Ok(Value::Number(0.0)), + } +} + +fn info(a: &[Arg], _c: &mut dyn Ctx) -> R { + let t = text(a, 0)?.to_ascii_lowercase(); + let v: Value = match t.as_str() { + "directory" => "/".into(), + "numfile" => Value::Number(1.0), + "origin" => "$A:$A$1".into(), + "osversion" => "SheetCraft".into(), + "recalc" => "Automatic".into(), + "release" => "16.0".into(), + "system" => "pcdos".into(), + _ => return Err(CellError::Value), + }; + Ok(v) +} + +// --------------------------------------------------------------------------------------------- +// Logical + +/// Collects booleans for AND/OR/XOR: references and arrays contribute numbers and booleans +/// (text and blanks skipped); direct scalars coerce (empty literal = FALSE). +fn logicals(a: &[Arg]) -> R> { + let mut out = Vec::new(); + for arg in a { + match &arg.value { + Value::Array(arr) => { + for v in arr.iter() { + match v { + Value::Number(n) => out.push(*n != 0.0), + Value::Bool(b) => out.push(*b), + Value::Error(e) => return Err(*e), + _ => {} + } + } + } + Value::Error(e) => return Err(*e), + Value::Number(n) => out.push(*n != 0.0), + Value::Bool(b) => out.push(*b), + _ if arg.from_ref => {} + v => out.push(v.to_bool()?), + } + } + if out.is_empty() { + return Err(CellError::Value); + } + Ok(out) +} + +fn and(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(logicals(a)?.iter().all(|b| *b))) +} +fn or(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(logicals(a)?.iter().any(|b| *b))) +} +fn xor(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(logicals(a)?.iter().filter(|b| **b).count() % 2 == 1)) +} +fn not(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(!first(a).to_bool()?)) +} + +/// Applies `f` to scalar argument values. When any argument at a `drivers` position is an +/// array, `f` runs per element over the broadcast shape of all arguments; otherwise it receives +/// the arguments whole (so the chosen value may itself be an array). +fn drive(a: &[Arg], drivers: &dyn Fn(usize) -> bool, f: &dyn Fn(&[Value]) -> R) -> R { + let any = a.iter().enumerate().any(|(i, x)| drivers(i) && matches!(x.value, Value::Array(_))); + if !any { + let vals: Vec = a.iter().map(|x| x.value.clone()).collect(); + return f(&vals).map(empty_to_zero); + } + let (mut rows, mut cols) = (1usize, 1usize); + for x in a { + if let Value::Array(arr) = &x.value { + rows = rows.max(arr.rows); + cols = cols.max(arr.cols); + } + } + if rows.saturating_mul(cols) > MAX_CELLS { + return Err(CellError::Num); + } + let mut data = Vec::with_capacity(rows * cols); + let mut vals: Vec = Vec::with_capacity(a.len()); + for r in 0..rows { + for c in 0..cols { + vals.clear(); + vals.extend(a.iter().map(|x| match &x.value { + Value::Array(arr) => arr.get_broadcast(r, c), + v => v.clone(), + })); + let out = match f(&vals) { + Ok(Value::Array(arr)) => arr.data.first().cloned().unwrap_or(Value::Empty), + Ok(v) => v, + Err(e) => Value::Error(e), + }; + data.push(out); + } + } + Array::new(rows, cols, data).map(Value::from).ok_or(CellError::Calc) +} + +fn empty_to_zero(v: Value) -> Value { + if matches!(v, Value::Empty) { Value::Number(0.0) } else { v } +} + +fn if_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { + drive(a, &|i| i == 0, &|v| { + let cond = v.first().cloned().unwrap_or(Value::Empty).to_bool()?; + if cond { Ok(v.get(1).cloned().unwrap_or(Value::Bool(true))) } else { Ok(v.get(2).cloned().unwrap_or(Value::Bool(false))) } + }) +} + +fn ifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + if !a.len().is_multiple_of(2) { + return Err(CellError::NA); + } + drive(a, &|i| i % 2 == 0, &|v| { + for pair in v.chunks(2) { + if let [c, r] = pair + && c.to_bool()? + { + return Ok(r.clone()); + } + } + Err(CellError::NA) + }) +} + +fn iferror(a: &[Arg], _c: &mut dyn Ctx) -> R { + drive(a, &|i| i == 0, &|v| match v.first() { + Some(Value::Error(_)) => Ok(v.get(1).cloned().unwrap_or(Value::Empty)), + Some(x) => Ok(x.clone()), + None => Err(CellError::Value), + }) +} + +fn ifna(a: &[Arg], _c: &mut dyn Ctx) -> R { + drive(a, &|i| i == 0, &|v| match v.first() { + Some(Value::Error(CellError::NA)) => Ok(v.get(1).cloned().unwrap_or(Value::Empty)), + Some(x) => Ok(x.clone()), + None => Err(CellError::Value), + }) +} + +fn switch_equal(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Text(x), Value::Text(y)) => compare_text(x, y) == Ordering::Equal, + (Value::Number(x), Value::Number(y)) => x == y, + (Value::Bool(x), Value::Bool(y)) => x == y, + (Value::Empty, Value::Empty) => true, + (Value::Empty, Value::Number(x)) | (Value::Number(x), Value::Empty) => *x == 0.0, + (Value::Empty, Value::Text(t)) | (Value::Text(t), Value::Empty) => t.is_empty(), + _ => false, + } +} + +fn switch(a: &[Arg], _c: &mut dyn Ctx) -> R { + drive(a, &|i| i == 0, &|v| { + let Some((expr, rest)) = v.split_first() else { return Err(CellError::Value) }; + if let Value::Error(e) = expr { + return Err(*e); + } + let mut it = rest.chunks(2); + for pair in it.by_ref() { + match pair { + [case, result] => { + if let Value::Error(e) = case { + return Err(*e); + } + if switch_equal(expr, case) { + return Ok(result.clone()); + } + } + [default] => return Ok(default.clone()), + _ => {} + } + } + Err(CellError::NA) + }) +} + +fn choose(a: &[Arg], _c: &mut dyn Ctx) -> R { + drive(a, &|i| i == 0, &|v| { + let idx = v.first().cloned().unwrap_or(Value::Empty).to_number()?.trunc(); + if idx < 1.0 || idx >= v.len() as f64 { + return Err(CellError::Value); + } + v.get(idx as usize).cloned().ok_or(CellError::Value) + }) +} + +fn true_fn(_a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(true)) +} +fn false_fn(_a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(false)) +} + +// --------------------------------------------------------------------------------------------- +// Web and cube + +fn hyperlink(a: &[Arg], _c: &mut dyn Ctx) -> R { + if has(a, 1) { + let v = a.get(1).map(scalar).unwrap_or(Value::Empty); + return Ok(empty_to_zero(v)); + } + let link = a.first().map(scalar).unwrap_or(Value::Empty); + if let Value::Error(e) = link { + return Err(e); + } + Ok(link) +} + +fn encodeurl(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { + out.push(char::from(b)); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + Ok(Value::from(out)) +} + +fn value_err(_a: &[Arg], _c: &mut dyn Ctx) -> R { + Err(CellError::Value) +} + +pub(crate) fn specs() -> Vec { + vec![ + f!("ISBLANK", 1, 1, Information, S, "ISBLANK(value)", "TRUE when the value refers to an empty cell.", is_blank), + f!("ISERR", 1, 1, Information, S, "ISERR(value)", "TRUE when the value is any error other than #N/A.", is_err), + f!("ISERROR", 1, 1, Information, S, "ISERROR(value)", "TRUE when the value is any error.", is_error), + f!("ISNA", 1, 1, Information, S, "ISNA(value)", "TRUE when the value is the #N/A error.", is_na), + f!("ISNUMBER", 1, 1, Information, S, "ISNUMBER(value)", "TRUE when the value is a number.", is_number), + f!("ISTEXT", 1, 1, Information, S, "ISTEXT(value)", "TRUE when the value is text.", is_text), + f!("ISNONTEXT", 1, 1, Information, S, "ISNONTEXT(value)", "TRUE when the value is anything other than text.", is_nontext), + f!("ISLOGICAL", 1, 1, Information, S, "ISLOGICAL(value)", "TRUE when the value is TRUE or FALSE.", is_logical), + f!("ISEVEN", 1, 1, Information, S, "ISEVEN(number)", "TRUE when the integer part of a number is even.", is_even), + f!("ISODD", 1, 1, Information, S, "ISODD(number)", "TRUE when the integer part of a number is odd.", is_odd), + f!( + "TYPE", + 1, + 1, + Information, + A, + "TYPE(value)", + "Returns a code for the kind of value: 1 number, 2 text, 4 logical, 16 error, 64 array.", + type_fn + ), + f!( + "ERROR.TYPE", + 1, + 1, + Information, + S, + "ERROR.TYPE(error_val)", + "Returns the number identifying an error value, or #N/A if it is not an error.", + error_type + ), + f!("NA", 0, 0, Information, S, "NA()", "Returns the #N/A error value.", na), + f!("N", 1, 1, Information, A, "N(value)", "Converts a value to a number: numbers stay, TRUE is 1, everything else is 0.", n_fn), + f!("INFO", 1, 1, Information, S, "INFO(type_text)", "Returns information about the operating environment.", info), + f!("AND", 1, VAR, Logical, A, "AND(logical1, [logical2], ...)", "TRUE when every argument is TRUE.", and), + f!("OR", 1, VAR, Logical, A, "OR(logical1, [logical2], ...)", "TRUE when at least one argument is TRUE.", or), + f!("XOR", 1, VAR, Logical, A, "XOR(logical1, [logical2], ...)", "TRUE when an odd number of arguments are TRUE.", xor), + f!("NOT", 1, 1, Logical, S, "NOT(logical)", "Reverses a logical value.", not), + f!("TRUE", 0, 0, Logical, S, "TRUE()", "Returns the logical value TRUE.", true_fn), + f!("FALSE", 0, 0, Logical, S, "FALSE()", "Returns the logical value FALSE.", false_fn), + f!( + "IF", + 2, + 3, + Logical, + A, + "IF(logical_test, [value_if_true], [value_if_false])", + "Returns one value when a condition is TRUE and another when it is FALSE.", + if_fn + ), + f!( + "IFS", + 2, + VAR, + Logical, + A, + "IFS(logical_test1, value_if_true1, ...)", + "Returns the value paired with the first condition that is TRUE.", + ifs + ), + f!( + "IFERROR", + 2, + 2, + Logical, + A, + "IFERROR(value, value_if_error)", + "Returns a fallback when the value is an error, otherwise the value.", + iferror + ), + f!("IFNA", 2, 2, Logical, A, "IFNA(value, value_if_na)", "Returns a fallback when the value is #N/A, otherwise the value.", ifna), + f!( + "SWITCH", + 3, + VAR, + Logical, + A, + "SWITCH(expression, value1, result1, [default_or_value2, result2], ...)", + "Compares an expression with a list of values and returns the result paired with the first match.", + switch + ), + f!("CHOOSE", 2, VAR, Lookup, A, "CHOOSE(index_num, value1, [value2], ...)", "Picks a value from the list by its position.", choose), + f!( + "HYPERLINK", + 1, + 2, + Information, + S, + "HYPERLINK(link_location, [friendly_name])", + "Creates a link; the cell shows the friendly name, or the link itself.", + hyperlink + ), + f!("ENCODEURL", 1, 1, Web, S, "ENCODEURL(text)", "Percent-encodes text for use in a URL.", encodeurl), + f!("WEBSERVICE", 1, 1, Web, S, "WEBSERVICE(url)", "Fetches data from a web service (not available; returns #VALUE!).", value_err), + f!("FILTERXML", 2, 2, Web, S, "FILTERXML(xml, xpath)", "Extracts data from XML with an XPath (not available; returns #VALUE!).", value_err), + f!( + "CUBEKPIMEMBER", + 3, + 4, + Cube, + A, + "CUBEKPIMEMBER(connection, kpi_name, kpi_property, [caption])", + "Returns a key performance indicator property from a cube (no cube connections; #N/A).", + na + ), + f!( + "CUBEMEMBER", + 2, + 3, + Cube, + A, + "CUBEMEMBER(connection, member_expression, [caption])", + "Returns a member or tuple from a cube (no cube connections; #N/A).", + na + ), + f!( + "CUBEMEMBERPROPERTY", + 3, + 3, + Cube, + A, + "CUBEMEMBERPROPERTY(connection, member_expression, property)", + "Returns a property of a cube member (no cube connections; #N/A).", + na + ), + f!( + "CUBERANKEDMEMBER", + 3, + 4, + Cube, + A, + "CUBERANKEDMEMBER(connection, set_expression, rank, [caption])", + "Returns the nth member of a cube set (no cube connections; #N/A).", + na + ), + f!( + "CUBESET", + 2, + 5, + Cube, + A, + "CUBESET(connection, set_expression, [caption], [sort_order], [sort_by])", + "Defines a set of cube members (no cube connections; #N/A).", + na + ), + f!("CUBESETCOUNT", 1, 1, Cube, A, "CUBESETCOUNT(set)", "Counts the items in a cube set (no cube connections; #N/A).", na), + f!( + "CUBEVALUE", + 1, + VAR, + Cube, + A, + "CUBEVALUE(connection, [member_expression1], ...)", + "Returns an aggregated value from a cube (no cube connections; #N/A).", + na + ), + ] +} + +#[cfg(test)] +mod tests { + use sheetcraft_core::{CellError, Value}; + + use crate::util::testutil::*; + + #[test] + fn is_functions() { + assert_eq!(ev("ISBLANK", vec![rf(Value::Empty)]), Value::Bool(true)); + assert_eq!(ev("ISBLANK", vec![t("")]), Value::Bool(false)); + assert_eq!(ev("ISERR", vec![e(CellError::NA)]), Value::Bool(false)); + assert_eq!(ev("ISERR", vec![e(CellError::Div0)]), Value::Bool(true)); + assert_eq!(ev("ISERROR", vec![e(CellError::NA)]), Value::Bool(true)); + assert_eq!(ev("ISERROR", vec![n(1.0)]), Value::Bool(false)); + assert_eq!(ev("ISNA", vec![e(CellError::NA)]), Value::Bool(true)); + assert_eq!(ev("ISNA", vec![e(CellError::Value)]), Value::Bool(false)); + assert_eq!(ev("ISNUMBER", vec![n(1.0)]), Value::Bool(true)); + assert_eq!(ev("ISNUMBER", vec![t("1")]), Value::Bool(false)); + assert_eq!(ev("ISTEXT", vec![t("a")]), Value::Bool(true)); + assert_eq!(ev("ISNONTEXT", vec![rf(Value::Empty)]), Value::Bool(true)); + assert_eq!(ev("ISNONTEXT", vec![t("a")]), Value::Bool(false)); + assert_eq!(ev("ISLOGICAL", vec![b(false)]), Value::Bool(true)); + assert_eq!(ev("ISLOGICAL", vec![n(0.0)]), Value::Bool(false)); + assert_eq!(ev("ISEVEN", vec![n(-2.5)]), Value::Bool(true)); + assert_eq!(ev("ISODD", vec![n(3.9)]), Value::Bool(true)); + assert_eq!(ev("ISODD", vec![n(-3.0)]), Value::Bool(true)); + is_err(ev("ISEVEN", vec![t("x")]), CellError::Value); + is_err(ev("ISEVEN", vec![b(true)]), CellError::Value); + assert_eq!(ev("ISEVEN", vec![rf(Value::Empty)]), Value::Bool(true)); + assert_eq!(rows_of(&ev("ISEVEN", vec![av(row(&[1.0, 2.0]))])), vec![vec![false.into(), true.into()]]); + let range = arr(vec![vec![nv(1.0), Value::Empty], vec![tv("x"), Value::Empty]]); + assert_eq!(rows_of(&ev("ISBLANK", vec![rf(range)])), vec![vec![false.into(), true.into()], vec![false.into(), true.into()]]); + } + + #[test] + fn type_error_n() { + close(ev("TYPE", vec![n(1.0)]), 1.0); + close(ev("TYPE", vec![t("a")]), 2.0); + close(ev("TYPE", vec![b(true)]), 4.0); + close(ev("TYPE", vec![e(CellError::Ref)]), 16.0); + close(ev("TYPE", vec![av(row(&[1.0, 2.0]))]), 64.0); + close(ev("TYPE", vec![rf(arr(vec![vec![tv("x")]]))]), 2.0); + close(ev("TYPE", vec![rf(Value::Empty)]), 1.0); + close(ev("ERROR.TYPE", vec![e(CellError::Div0)]), 2.0); + close(ev("ERROR.TYPE", vec![e(CellError::NA)]), 7.0); + is_err(ev("ERROR.TYPE", vec![n(1.0)]), CellError::NA); + is_err(ev("NA", vec![]), CellError::NA); + close(ev("N", vec![n(7.0)]), 7.0); + close(ev("N", vec![b(true)]), 1.0); + close(ev("N", vec![t("7")]), 0.0); + is_err(ev("N", vec![e(CellError::Num)]), CellError::Num); + close(ev("N", vec![av(row(&[3.0, 4.0]))]), 3.0); + is_text(ev("INFO", vec![t("RECALC")]), "Automatic"); + is_err(ev("INFO", vec![t("bogus")]), CellError::Value); + } + + #[test] + fn logical() { + assert_eq!(ev("AND", vec![b(true), n(1.0)]), Value::Bool(true)); + assert_eq!(ev("AND", vec![b(true), n(0.0)]), Value::Bool(false)); + assert_eq!(ev("AND", vec![rf(arr(vec![vec![b(true).value, tv("x"), Value::Empty]]))]), Value::Bool(true)); + is_err(ev("AND", vec![rf(arr(vec![vec![tv("x")]]))]), CellError::Value); + is_err(ev("AND", vec![t("abc")]), CellError::Value); + assert_eq!(ev("AND", vec![t("TRUE")]), Value::Bool(true)); + is_err(ev("OR", vec![b(false), e(CellError::Div0)]), CellError::Div0); + assert_eq!(ev("OR", vec![b(false), n(2.0)]), Value::Bool(true)); + assert_eq!(ev("OR", vec![b(false), b(false)]), Value::Bool(false)); + assert_eq!(ev("XOR", vec![b(true), b(true)]), Value::Bool(false)); + assert_eq!(ev("XOR", vec![b(true), b(true), b(true)]), Value::Bool(true)); + assert_eq!(ev("NOT", vec![b(false)]), Value::Bool(true)); + assert_eq!(ev("NOT", vec![n(5.0)]), Value::Bool(false)); + is_err(ev("NOT", vec![t("x")]), CellError::Value); + assert_eq!(ev("TRUE", vec![]), Value::Bool(true)); + assert_eq!(ev("FALSE", vec![]), Value::Bool(false)); + } + + #[test] + fn conditionals() { + is_text(ev("IF", vec![b(true), t("y"), t("n")]), "y"); + is_text(ev("IF", vec![n(0.0), t("y"), t("n")]), "n"); + assert_eq!(ev("IF", vec![b(false), t("y")]), Value::Bool(false)); + close(ev("IF", vec![b(true), empty()]), 0.0); + is_err(ev("IF", vec![e(CellError::Ref), n(1.0), n(2.0)]), CellError::Ref); + let r = ev("IF", vec![av(arr(vec![vec![b(true).value, b(false).value]])), av(row(&[1.0, 2.0])), av(row(&[3.0, 4.0]))]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0), nv(4.0)]]); + // Scalar condition returns a whole array value. + assert_eq!(rows_of(&ev("IF", vec![b(true), av(row(&[1.0, 2.0])), n(0.0)])), vec![vec![nv(1.0), nv(2.0)]]); + is_text(ev("IFS", vec![b(false), t("a"), b(true), t("b")]), "b"); + is_err(ev("IFS", vec![b(false), t("a")]), CellError::NA); + close(ev("IFERROR", vec![e(CellError::Div0), n(9.0)]), 9.0); + close(ev("IFERROR", vec![n(3.0), n(9.0)]), 3.0); + let r = ev("IFERROR", vec![av(arr(vec![vec![nv(1.0), Value::Error(CellError::Div0)]])), n(0.0)]); + assert_eq!(rows_of(&r), vec![vec![nv(1.0), nv(0.0)]]); + close(ev("IFNA", vec![e(CellError::NA), n(1.0)]), 1.0); + is_err(ev("IFNA", vec![e(CellError::Value), n(1.0)]), CellError::Value); + is_text(ev("SWITCH", vec![n(2.0), n(1.0), t("one"), n(2.0), t("two")]), "two"); + is_text(ev("SWITCH", vec![n(3.0), n(1.0), t("one"), t("other")]), "other"); + is_err(ev("SWITCH", vec![n(3.0), n(1.0), t("one")]), CellError::NA); + is_text(ev("SWITCH", vec![t("B"), t("a"), t("x"), t("b"), t("y")]), "y"); + is_text(ev("CHOOSE", vec![n(2.0), t("a"), t("b"), t("c")]), "b"); + is_err(ev("CHOOSE", vec![n(4.0), t("a"), t("b"), t("c")]), CellError::Value); + is_err(ev("CHOOSE", vec![n(0.0), t("a")]), CellError::Value); + let r = ev("CHOOSE", vec![av(row(&[1.0, 2.0])), av(row(&[10.0, 20.0])), av(row(&[30.0, 40.0]))]); + assert_eq!(rows_of(&r), vec![vec![nv(10.0), nv(40.0)]]); + } + + #[test] + fn web_and_cube() { + is_text(ev("HYPERLINK", vec![t("http://x.com")]), "http://x.com"); + is_text(ev("HYPERLINK", vec![t("http://x.com"), t("Click")]), "Click"); + close(ev("HYPERLINK", vec![t("http://x.com"), n(5.0)]), 5.0); + is_text(ev("ENCODEURL", vec![t("a b&c/é")]), "a%20b%26c%2F%C3%A9"); + is_err(ev("WEBSERVICE", vec![t("http://x")]), CellError::Value); + is_err(ev("FILTERXML", vec![t(""), t("//a")]), CellError::Value); + is_err(ev("CUBEVALUE", vec![t("conn")]), CellError::NA); + is_err(ev("CUBESETCOUNT", vec![t("set")]), CellError::NA); + } +} diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs new file mode 100644 index 00000000..5d1af419 --- /dev/null +++ b/crates/functions/src/lib.rs @@ -0,0 +1,279 @@ +//! SheetCraft worksheet function library. +//! +//! Every function is described by a [`FnSpec`] (name, arity, category, which parameters take +//! scalars, a signature and a one-line description) and implemented as a plain Rust function over +//! already-evaluated arguments. The calculation engine looks functions up by name with +//! [`lookup`] and invokes them through [`call`], which applies Excel's automatic array lifting: +//! passing an array where a scalar is expected evaluates the function once per element. +//! +//! Special forms that need references, laziness or hidden-row information (ROW, OFFSET, +//! INDIRECT, LET, LAMBDA, SUBTOTAL, AGGREGATE, TEXT…) are evaluated by the host; this crate +//! exports [`aggregate_values`] for SUBTOTAL/AGGREGATE. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::collections::HashMap; +use std::sync::OnceLock; + +use sheetcraft_core::{CellError, DateSystem, Value}; + +/// Builds a [`FnSpec`] whose implementation returns `Result`. +/// +/// `f!(NAME, min, max, Category, scalar_args, "SIG", "description", path::to::impl)`; prefix the +/// arguments with `volatile;` for volatile functions. +macro_rules! f { + (volatile; $name:literal, $min:expr, $max:expr, $cat:ident, $scal:expr, $sig:literal, $desc:literal, $imp:expr) => { + $crate::FnSpec { + name: $name, + min_args: $min, + max_args: $max, + category: $crate::Category::$cat, + volatile: true, + scalar_args: $scal, + signature: $sig, + description: $desc, + imp: { + fn w(a: &[$crate::Arg], c: &mut dyn $crate::Ctx) -> sheetcraft_core::Value { + $crate::util::finish(($imp)(a, c)) + } + w + }, + } + }; + ($name:literal, $min:expr, $max:expr, $cat:ident, $scal:expr, $sig:literal, $desc:literal, $imp:expr) => { + $crate::FnSpec { + name: $name, + min_args: $min, + max_args: $max, + category: $crate::Category::$cat, + volatile: false, + scalar_args: $scal, + signature: $sig, + description: $desc, + imp: { + fn w(a: &[$crate::Arg], c: &mut dyn $crate::Ctx) -> sheetcraft_core::Value { + $crate::util::finish(($imp)(a, c)) + } + w + }, + } + }; +} + +pub(crate) mod criteria; +pub(crate) mod database; +pub(crate) mod date; +pub(crate) mod dynamic; +pub(crate) mod engineering; +pub(crate) mod financial; +pub(crate) mod info; +pub(crate) mod lift; +pub(crate) mod lookup; +pub(crate) mod math; +pub(crate) mod special; +pub(crate) mod stat; +pub(crate) mod text; +pub(crate) mod util; + +pub use stat::{aggregate_values, aggregate_values_k}; + +/// An evaluated argument. Ranges arrive as `Value::Array` (blank cells = `Value::Empty`) with +/// `from_ref = true`. +#[derive(Clone, Debug)] +pub struct Arg { + pub value: Value, + pub from_ref: bool, +} + +impl Arg { + /// A literal (non-reference) argument. + pub fn val(value: impl Into) -> Arg { + Arg { value: value.into(), from_ref: false } + } + /// An argument that came from a cell or range reference. + pub fn reference(value: impl Into) -> Arg { + Arg { value: value.into(), from_ref: true } + } +} + +/// Services a function may need from the host. +pub trait Ctx { + fn date_system(&self) -> DateSystem; + /// Current local date-time serial. + fn now_serial(&self) -> f64; + /// Uniform in [0, 1). + fn random(&mut self) -> f64; +} + +pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Category { + Financial, + Logical, + Text, + DateTime, + Lookup, + MathTrig, + Statistical, + Engineering, + Information, + Database, + Compatibility, + Web, + Cube, +} + +pub struct FnSpec { + /// Upper-case, without any `_xlfn.` prefix. + pub name: &'static str, + pub min_args: usize, + /// `usize::MAX` for variadic functions (Excel allows 255 arguments). + pub max_args: usize, + pub category: Category, + pub volatile: bool, + /// For automatic array lifting: true for each parameter position that expects a scalar. When + /// an argument at a scalar position is an array, the function is applied element-wise + /// (broadcast like Excel's dynamic arrays). The last entry repeats for variadic tails; an + /// empty slice means no lifting. + pub scalar_args: &'static [bool], + pub signature: &'static str, + pub description: &'static str, + pub imp: FnImpl, +} + +impl std::fmt::Debug for FnSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FnSpec").field("name", &self.name).field("min_args", &self.min_args).field("max_args", &self.max_args).finish() + } +} + +impl FnSpec { + /// Whether parameter `i` expects a scalar (and is therefore lifted over arrays). + pub fn is_scalar_arg(&self, i: usize) -> bool { + match self.scalar_args.get(i) { + Some(b) => *b, + None => self.scalar_args.last().copied().unwrap_or(false), + } + } +} + +/// Variadic marker for `max_args`. +pub(crate) const VAR: usize = usize::MAX; + +fn registry() -> &'static (Vec, HashMap<&'static str, usize>) { + static REG: OnceLock<(Vec, HashMap<&'static str, usize>)> = OnceLock::new(); + REG.get_or_init(|| { + let mut v: Vec = Vec::new(); + for list in [ + math::specs(), + stat::specs(), + text::specs(), + date::specs(), + lookup::specs(), + dynamic::specs(), + financial::specs(), + info::specs(), + engineering::specs(), + database::specs(), + ] { + v.extend(list); + } + let mut map = HashMap::with_capacity(v.len()); + for (i, s) in v.iter().enumerate() { + map.entry(s.name).or_insert(i); + } + (v, map) + }) +} + +/// Looks a function up by name (case-insensitive; `_xlfn.`, `_xlws.` and `_xludf.`-free names). +pub fn lookup(name: &str) -> Option<&'static FnSpec> { + let mut n = name.trim().to_ascii_uppercase(); + loop { + if let Some(r) = n.strip_prefix("_XLFN.") { + n = r.to_string(); + } else if let Some(r) = n.strip_prefix("_XLWS.") { + n = r.to_string(); + } else { + break; + } + } + let (v, map) = registry(); + map.get(n.as_str()).and_then(|&i| v.get(i)) +} + +/// Every registered function. +pub fn all() -> &'static [FnSpec] { + ®istry().0 +} + +/// Calls a function with array lifting applied. Wrong arity gives `#VALUE!`. +pub fn call(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value { + if args.len() < spec.min_args || args.len() > spec.max_args || args.len() > 255 { + return Value::Error(CellError::Value); + } + lift::call_lifted(spec, args, ctx) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_is_consistent() { + let mut seen = std::collections::HashSet::new(); + for s in all() { + assert!(seen.insert(s.name), "duplicate function {}", s.name); + assert_eq!(s.name, s.name.to_ascii_uppercase(), "{} not upper-case", s.name); + assert!(s.min_args <= s.max_args, "{} arity", s.name); + assert!(!s.description.is_empty() && !s.signature.is_empty(), "{} docs", s.name); + } + println!("registered functions: {}", all().len()); + assert!(all().len() >= 400); + assert!(lookup("_xlfn._xlws.sort").is_some_and(|s| s.name == "SORT")); + assert!(lookup("nosuchfn").is_none()); + for s in all() { + assert!(lookup(&format!("_xlfn.{}", s.name.to_ascii_lowercase())).is_some_and(|x| x.name == s.name)); + } + } + + #[test] + fn hostile_inputs_never_panic() { + use crate::util::testutil::*; + use sheetcraft_core::{Array, CellError}; + let mixed = Value::from( + Array::new(2, 3, vec![Value::Number(1.0), Value::from("x"), Value::Empty, Value::Bool(true), Value::Number(-1e300), Value::Number(0.5)]) + .unwrap(), + ); + let patterns: Vec = vec![ + n(0.0), + n(-1.0), + n(1e308), + n(-1e308), + n(1e10), + n(2.5), + t(""), + t("abc"), + t("*?~"), + b(true), + e(CellError::NA), + empty(), + av(mixed.clone()), + rf(mixed), + rf(col(&[3.0, 1.0, 2.0])), + ]; + for spec in all() { + let max = spec.max_args.min(spec.min_args + 3); + for count in spec.min_args..=max { + for p in &patterns { + let args = vec![p.clone(); count]; + let _ = call(spec, &args, &mut TestCtx::default()); + } + // Mixed: cycle through patterns. + let args: Vec = (0..count).map(|i| patterns[(i * 7 + count) % patterns.len()].clone()).collect(); + let _ = call(spec, &args, &mut TestCtx::default()); + } + } + } +} diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs new file mode 100644 index 00000000..998b4375 --- /dev/null +++ b/crates/functions/src/lift.rs @@ -0,0 +1,97 @@ +//! Automatic array lifting: a function given an array where it expects a scalar is applied to +//! each element, broadcasting single rows/columns like Excel's dynamic arrays. + +use sheetcraft_core::{Array, CellError, Value}; + +use crate::util::MAX_CELLS; +use crate::{Arg, Ctx, FnSpec}; + +pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value { + let mut rows = 0usize; + let mut cols = 0usize; + let mut any = false; + for (i, a) in args.iter().enumerate() { + if spec.is_scalar_arg(i) + && let Value::Array(arr) = &a.value + { + any = true; + rows = rows.max(arr.rows); + cols = cols.max(arr.cols); + } + } + if !any { + return (spec.imp)(args, ctx); + } + if rows.saturating_mul(cols) > MAX_CELLS { + return Value::Error(CellError::Num); + } + let mut cell_args: Vec = args.to_vec(); + let mut data = Vec::with_capacity(rows * cols); + for r in 0..rows { + for c in 0..cols { + for (i, a) in args.iter().enumerate() { + if spec.is_scalar_arg(i) + && let Value::Array(arr) = &a.value + && let Some(slot) = cell_args.get_mut(i) + { + let v = arr.get_broadcast(r, c); + slot.value = if let Value::Array(inner) = &v { inner.data.first().cloned().unwrap_or(Value::Empty) } else { v }; + } + } + let out = (spec.imp)(&cell_args, ctx); + data.push(match out { + Value::Array(a) => a.data.first().cloned().unwrap_or(Value::Empty), + v => v, + }); + } + } + if rows == 1 && cols == 1 { + return data.pop().unwrap_or(Value::Empty); + } + Array::new(rows, cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Calc)) +} + +#[cfg(test)] +mod tests { + use sheetcraft_core::{CellError, Value}; + + use crate::util::testutil::*; + use crate::util::{R, num}; + use crate::{Arg, Ctx, FnSpec}; + + fn add(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Number(num(a, 0)? + num(a, 1)?)) + } + + fn spec() -> FnSpec { + f!("ADD2", 2, 2, MathTrig, &[true, true], "ADD2(a, b)", "Adds two numbers.", add) + } + + #[test] + fn broadcasts_row_and_column() { + let s = spec(); + let out = crate::call(&s, &[av(row(&[1.0, 2.0, 3.0])), av(col(&[10.0, 20.0]))], &mut TestCtx::default()); + assert_eq!(rows_of(&out), vec![vec![nv(11.0), nv(12.0), nv(13.0)], vec![nv(21.0), nv(22.0), nv(23.0)]]); + } + + #[test] + fn mismatched_sizes_pad_with_na() { + let s = spec(); + let out = crate::call(&s, &[av(row(&[1.0, 2.0, 3.0])), av(row(&[1.0, 1.0]))], &mut TestCtx::default()); + assert_eq!(rows_of(&out), vec![vec![nv(2.0), nv(3.0), Value::Error(CellError::NA)]]); + } + + #[test] + fn single_cell_unwraps_and_arity() { + let s = spec(); + let out = crate::call(&s, &[rf(col(&[4.0])), n(1.0)], &mut TestCtx::default()); + assert_eq!(out, nv(5.0)); + is_err(crate::call(&s, &[n(1.0)], &mut TestCtx::default()), CellError::Value); + let huge = crate::call( + &s, + &[av(sheetcraft_core::Array::filled(1, 100_000, nv(1.0)).into()), av(sheetcraft_core::Array::filled(100_000, 1, nv(1.0)).into())], + &mut TestCtx::default(), + ); + is_err(huge, CellError::Num); + } +} diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs new file mode 100644 index 00000000..23b4e256 --- /dev/null +++ b/crates/functions/src/lookup.rs @@ -0,0 +1,690 @@ +//! Lookup and reference functions over evaluated arrays: VLOOKUP, HLOOKUP, LOOKUP, MATCH, +//! XLOOKUP, XMATCH, INDEX, ROWS, COLUMNS, TRANSPOSE, ADDRESS. +//! +//! Reference-only behaviour (INDEX returning a reference, OFFSET, ROW…) belongs to the host. + +use std::cmp::Ordering; + +use sheetcraft_core::{Array, CellError, Value, col_to_letters, compare}; + +use crate::criteria::{has_wildcards, lookup_equal}; +use crate::util::{R, arg, array_val, as_array, has, int, num, opt_bool, opt_int, opt_num, text, text_val}; +use crate::{Arg, Ctx, FnSpec}; + +// --------------------------------------------------------------------------------------------- +// Search helpers + +/// Lookup needle: the scalar to look for. Blank looks for 0; errors propagate. +fn needle(args: &[Arg], i: usize) -> R { + match arg(args, i)?.value.scalar() { + Value::Error(e) => Err(e), + Value::Empty => Ok(Value::Number(0.0)), + v => Ok(v), + } +} + +fn same_type(a: &Value, b: &Value) -> bool { + matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_))) +} + +/// Exact search (first match, or last when `reverse`). Text needles match wildcards when `wild`. +fn find_exact(x: &Value, hay: &[Value], wild: bool, reverse: bool) -> Option { + let wild = wild && matches!(x, Value::Text(t) if has_wildcards(t)); + if reverse { hay.iter().rposition(|v| lookup_equal(x, v, wild)) } else { hay.iter().position(|v| lookup_equal(x, v, wild)) } +} + +/// Binary search over the entries of the needle's type, assuming they are sorted ascending +/// (`desc = false`) or descending. Returns the position of the last entry that is `<= x` +/// (ascending) or `>= x` (descending), like Excel's approximate match. +fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option { + let idx: Vec = hay.iter().enumerate().filter(|(_, v)| same_type(x, v)).map(|(i, _)| i).collect(); + let (mut lo, mut hi) = (0usize, idx.len()); + // Find the first filtered position where the "passes" predicate fails. + while lo < hi { + let mid = lo + (hi - lo) / 2; + let v = idx.get(mid).and_then(|&i| hay.get(i))?; + let o = compare(v, x); + let ok = if desc { o != Ordering::Less } else { o != Ordering::Greater }; + if ok { + lo = mid + 1; + } else { + hi = mid; + } + } + if lo == 0 { None } else { idx.get(lo - 1).copied() } +} + +/// Binary search returning the first entry `>= x` (ascending) or `<= x` (descending). +fn bsearch_first(x: &Value, hay: &[Value], desc: bool) -> Option { + let idx: Vec = hay.iter().enumerate().filter(|(_, v)| same_type(x, v)).map(|(i, _)| i).collect(); + let (mut lo, mut hi) = (0usize, idx.len()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + let v = idx.get(mid).and_then(|&i| hay.get(i))?; + let o = compare(v, x); + let before = if desc { o == Ordering::Greater } else { o == Ordering::Less }; + if before { + lo = mid + 1; + } else { + hi = mid; + } + } + idx.get(lo).copied() +} + +/// A 1-D view of an array (row or column), `None` when it is 2-D. +fn vector(a: &Array) -> Option> { + if a.rows == 1 || a.cols == 1 { Some(a.data.clone()) } else { None } +} + +fn column(a: &Array, c: usize) -> Vec { + (0..a.rows).map(|r| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect() +} + +fn row(a: &Array, r: usize) -> Vec { + (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect() +} + +/// A looked-up cell value: blanks read as 0 like a reference to an empty cell. +fn cell_result(v: Value) -> Value { + if matches!(v, Value::Empty) { Value::Number(0.0) } else { v } +} + +// --------------------------------------------------------------------------------------------- +// VLOOKUP / HLOOKUP / LOOKUP / MATCH + +fn vh_lookup(args: &[Arg], vertical: bool) -> R { + let x = needle(args, 0)?; + let table = as_array(&arg(args, 1)?.value); + if let Value::Error(e) = &arg(args, 1)?.value { + return Err(*e); + } + let idx = num(args, 2)?; + if idx < 1.0 { + return Err(CellError::Value); + } + let idx = idx.trunc() as usize - 1; + let approx = opt_bool(args, 3, true)?; + let (keys, len) = if vertical { (column(&table, 0), table.cols) } else { (row(&table, 0), table.rows) }; + if idx >= len { + return Err(CellError::Ref); + } + let pos = if approx { bsearch_last(&x, &keys, false) } else { find_exact(&x, &keys, true, false) }; + let pos = pos.ok_or(CellError::NA)?; + let v = if vertical { table.get(pos, idx) } else { table.get(idx, pos) }; + Ok(cell_result(v.cloned().unwrap_or(Value::Empty))) +} + +fn vlookup(a: &[Arg], _c: &mut dyn Ctx) -> R { + vh_lookup(a, true) +} + +fn hlookup(a: &[Arg], _c: &mut dyn Ctx) -> R { + vh_lookup(a, false) +} + +fn lookup(args: &[Arg], _c: &mut dyn Ctx) -> R { + let x = needle(args, 0)?; + let lv = &arg(args, 1)?.value; + if let Value::Error(e) = lv { + return Err(*e); + } + let la = as_array(lv); + if args.len() >= 3 { + let keys = if la.rows == 1 || la.cols == 1 { la.data.clone() } else { column(&la, 0) }; + let pos = bsearch_last(&x, &keys, false).ok_or(CellError::NA)?; + let rv = &arg(args, 2)?.value; + if let Value::Error(e) = rv { + return Err(*e); + } + let ra = as_array(rv); + let v = if ra.rows == 1 || ra.cols == 1 { + ra.data.get(pos).cloned() + } else if ra.rows >= keys.len() { + ra.get(pos, 0).cloned() + } else { + None + }; + return v.map(cell_result).ok_or(CellError::NA); + } + // Array form: search the first row of a wide array, else the first column; return the + // matching entry from the last row / column. + if la.cols > la.rows { + let keys = row(&la, 0); + let pos = bsearch_last(&x, &keys, false).ok_or(CellError::NA)?; + Ok(cell_result(la.get(la.rows - 1, pos).cloned().unwrap_or(Value::Empty))) + } else { + let keys = column(&la, 0); + let pos = bsearch_last(&x, &keys, false).ok_or(CellError::NA)?; + Ok(cell_result(la.get(pos, la.cols - 1).cloned().unwrap_or(Value::Empty))) + } +} + +fn match_fn(args: &[Arg], _c: &mut dyn Ctx) -> R { + let x = needle(args, 0)?; + let av = &arg(args, 1)?.value; + if let Value::Error(e) = av { + return Err(*e); + } + let hay = vector(&as_array(av)).ok_or(CellError::NA)?; + let mt = opt_num(args, 2, 1.0)?; + let pos = if mt == 0.0 { + find_exact(&x, &hay, true, false) + } else if mt > 0.0 { + bsearch_last(&x, &hay, false) + } else { + bsearch_last(&x, &hay, true) + }; + pos.map(|p| Value::Number((p + 1) as f64)).ok_or(CellError::NA) +} + +// --------------------------------------------------------------------------------------------- +// XLOOKUP / XMATCH + +/// Core XMATCH search: 0-based position in `hay`. +fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R> { + if !matches!(match_mode, -1..=2) || !matches!(search_mode, -2 | -1 | 1 | 2) { + return Err(CellError::Value); + } + if search_mode.abs() == 2 { + let desc = search_mode == -2; + if match_mode == 2 { + // Wildcards are not supported with binary search: Excel falls back to exact. + return Ok(bsearch_exact(x, hay, desc)); + } + return Ok(match match_mode { + 0 => bsearch_exact(x, hay, desc), + // Next smaller: last <= x (ascending) / first <= x (descending). + -1 => { + if desc { + bsearch_first(x, hay, true) + } else { + bsearch_last(x, hay, false) + } + } + _ => { + if desc { + bsearch_last(x, hay, true) + } else { + bsearch_first(x, hay, false) + } + } + }); + } + let reverse = search_mode == -1; + if let Some(p) = find_exact(x, hay, match_mode == 2, reverse) { + return Ok(Some(p)); + } + if match_mode == 0 || match_mode == 2 { + return Ok(None); + } + // Best candidate: the largest value below (mode -1) or smallest above (mode 1). + let order: Box> = if reverse { Box::new((0..hay.len()).rev()) } else { Box::new(0..hay.len()) }; + let mut best: Option = None; + for i in order { + let Some(v) = hay.get(i) else { continue }; + if !same_type(x, v) { + continue; + } + let o = compare(v, x); + let candidate = if match_mode == -1 { o == Ordering::Less } else { o == Ordering::Greater }; + if !candidate { + continue; + } + let better = match best.and_then(|b| hay.get(b)) { + None => true, + Some(bv) => { + let c = compare(v, bv); + if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less } + } + }; + if better { + best = Some(i); + } + } + Ok(best) +} + +fn bsearch_exact(x: &Value, hay: &[Value], desc: bool) -> Option { + let p = bsearch_first(x, hay, desc)?; + let v = hay.get(p)?; + if compare(v, x) == Ordering::Equal { Some(p) } else { None } +} + +fn xlookup(args: &[Arg], _c: &mut dyn Ctx) -> R { + let x = needle(args, 0)?; + let lv = &arg(args, 1)?.value; + if let Value::Error(e) = lv { + return Err(*e); + } + let rv = &arg(args, 2)?.value; + if let Value::Error(e) = rv { + return Err(*e); + } + let la = as_array(lv); + let ra = as_array(rv); + let hay = vector(&la).ok_or(CellError::Value)?; + // Orientation: a column lookup returns rows of the return array; a row lookup returns columns. + let vertical = if la.rows == 1 && la.cols == 1 { ra.rows == 1 || ra.cols != 1 } else { la.cols == 1 }; + if vertical && ra.rows != la.rows || !vertical && ra.cols != la.cols { + return Err(CellError::Value); + } + let mm = if has(args, 4) { opt_int(args, 4, 0)? } else { 0 }; + let sm = if has(args, 5) { opt_int(args, 5, 1)? } else { 1 }; + match xsearch(&x, &hay, mm, sm)? { + Some(p) => { + if vertical { + let r = row(&ra, p); + if r.len() == 1 { + return Ok(cell_result(r.into_iter().next().unwrap_or(Value::Empty))); + } + array_val(1, r.len(), r) + } else { + let c = column(&ra, p); + if c.len() == 1 { + return Ok(cell_result(c.into_iter().next().unwrap_or(Value::Empty))); + } + array_val(c.len(), 1, c) + } + } + None => { + if has(args, 3) { + Ok(arg(args, 3)?.value.clone()) + } else { + Err(CellError::NA) + } + } + } +} + +fn xmatch(args: &[Arg], _c: &mut dyn Ctx) -> R { + let x = needle(args, 0)?; + let lv = &arg(args, 1)?.value; + if let Value::Error(e) = lv { + return Err(*e); + } + let hay = vector(&as_array(lv)).ok_or(CellError::Value)?; + let mm = if has(args, 2) { int(args, 2)? } else { 0 }; + let sm = if has(args, 3) { int(args, 3)? } else { 1 }; + xsearch(&x, &hay, mm, sm)?.map(|p| Value::Number((p + 1) as f64)).ok_or(CellError::NA) +} + +// --------------------------------------------------------------------------------------------- +// INDEX / ROWS / COLUMNS / TRANSPOSE / ADDRESS + +fn index(args: &[Arg], _c: &mut dyn Ctx) -> R { + let av = &arg(args, 0)?.value; + if let Value::Error(e) = av { + return Err(*e); + } + let a = as_array(av); + let r = if has(args, 1) { num(args, 1)? } else { 0.0 }; + let c = if has(args, 2) { num(args, 2)? } else { 0.0 }; + if has(args, 3) && num(args, 3)?.trunc() != 1.0 { + return Err(CellError::Ref); + } + if r < 0.0 || c < 0.0 { + return Err(CellError::Value); + } + let (mut r, mut c) = (r.trunc() as usize, c.trunc() as usize); + // A single row with only one index: the index selects the column. + if a.rows == 1 && args.len() == 2 && a.cols > 1 { + c = r; + r = 1; + } + if r > a.rows || c > a.cols { + return Err(CellError::Ref); + } + match (r, c) { + (0, 0) => Ok(Value::Array(std::sync::Arc::new(a.into_owned()))), + (0, c) => { + let v = column(&a, c - 1); + if v.len() == 1 { + return Ok(v.into_iter().next().unwrap_or(Value::Empty)); + } + array_val(v.len(), 1, v) + } + (r, 0) => { + let v = row(&a, r - 1); + if v.len() == 1 { + return Ok(v.into_iter().next().unwrap_or(Value::Empty)); + } + array_val(1, v.len(), v) + } + (r, c) => a.get(r - 1, c - 1).cloned().ok_or(CellError::Ref), + } +} + +fn rows_fn(args: &[Arg], _c: &mut dyn Ctx) -> R { + match &arg(args, 0)?.value { + Value::Array(a) => Ok(Value::Number(a.rows as f64)), + Value::Error(e) => Err(*e), + _ => Ok(Value::Number(1.0)), + } +} + +fn columns_fn(args: &[Arg], _c: &mut dyn Ctx) -> R { + match &arg(args, 0)?.value { + Value::Array(a) => Ok(Value::Number(a.cols as f64)), + Value::Error(e) => Err(*e), + _ => Ok(Value::Number(1.0)), + } +} + +fn transpose(args: &[Arg], _c: &mut dyn Ctx) -> R { + match &arg(args, 0)?.value { + Value::Array(a) => Ok(Value::from(a.transpose())), + v => Ok(v.clone()), + } +} + +fn quote_sheet(s: &str) -> String { + let plain = + !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') && !s.chars().next().is_some_and(|c| c.is_ascii_digit()); + if plain { s.to_string() } else { format!("'{}'", s.replace('\'', "''")) } +} + +fn address(args: &[Arg], _c: &mut dyn Ctx) -> R { + let r = num(args, 0)?.trunc(); + let c = num(args, 1)?.trunc(); + let abs = if has(args, 2) { int(args, 2)? } else { 1 }; + let a1 = if has(args, 3) { opt_bool(args, 3, true)? } else { true }; + if !(1.0..=1_048_576.0).contains(&r) || !(1.0..=16_384.0).contains(&c) || !(1..=4).contains(&abs) { + return Err(CellError::Value); + } + let (ri, ci) = (r as u32, c as u32); + let (row_abs, col_abs) = match abs { + 1 => (true, true), + 2 => (true, false), + 3 => (false, true), + _ => (false, false), + }; + let cell = if a1 { + format!("{}{}{}{}", if col_abs { "$" } else { "" }, col_to_letters(ci - 1), if row_abs { "$" } else { "" }, ri) + } else { + let rp = if row_abs { format!("R{ri}") } else { format!("R[{ri}]") }; + let cp = if col_abs { format!("C{ci}") } else { format!("C[{ci}]") }; + format!("{rp}{cp}") + }; + if has(args, 4) { + let sheet = text(args, 4)?; + if sheet.is_empty() { + return text_val(format!("!{cell}")); + } + return text_val(format!("{}!{cell}", quote_sheet(&sheet))); + } + text_val(cell) +} + +const XL: &[bool] = &[true, false, false, false, false, false]; +const VL: &[bool] = &[true, false, true, true]; +const ML: &[bool] = &[true, false, true, true]; +const IDX: &[bool] = &[false, true, true, true]; + +pub(crate) fn specs() -> Vec { + use crate::util::{A, S}; + vec![ + f!( + "VLOOKUP", + 3, + 4, + Lookup, + VL, + "VLOOKUP(lookup_value, table_array, col_index_num, [range_lookup])", + "Finds a value in the first column of a table and returns the value in the same row of another column.", + vlookup + ), + f!( + "HLOOKUP", + 3, + 4, + Lookup, + VL, + "HLOOKUP(lookup_value, table_array, row_index_num, [range_lookup])", + "Finds a value in the first row of a table and returns the value in the same column of another row.", + hlookup + ), + f!( + "LOOKUP", + 2, + 3, + Lookup, + &[true, false, false], + "LOOKUP(lookup_value, lookup_vector, [result_vector])", + "Approximate lookup in a sorted vector or the first row/column of an array.", + lookup + ), + f!( + "MATCH", + 2, + 3, + Lookup, + &[true, false, true], + "MATCH(lookup_value, lookup_array, [match_type])", + "Returns the 1-based position of a value in a row or column.", + match_fn + ), + f!( + "XLOOKUP", + 3, + 6, + Lookup, + XL, + "XLOOKUP(lookup_value, lookup_array, return_array, [if_not_found], [match_mode], [search_mode])", + "Searches a row or column and returns the matching item, row or column from another array.", + xlookup + ), + f!( + "XMATCH", + 2, + 4, + Lookup, + ML, + "XMATCH(lookup_value, lookup_array, [match_mode], [search_mode])", + "Returns the position of a value in a row or column with flexible match and search modes.", + xmatch + ), + f!( + "INDEX", + 2, + 4, + Lookup, + IDX, + "INDEX(array, row_num, [column_num], [area_num])", + "Returns the element, row or column of an array at the given position.", + index + ), + f!("ROWS", 1, 1, Lookup, A, "ROWS(array)", "Counts the rows in an array or range.", rows_fn), + f!("COLUMNS", 1, 1, Lookup, A, "COLUMNS(array)", "Counts the columns in an array or range.", columns_fn), + f!("TRANSPOSE", 1, 1, Lookup, A, "TRANSPOSE(array)", "Swaps the rows and columns of an array.", transpose), + f!( + "ADDRESS", + 2, + 5, + Lookup, + S, + "ADDRESS(row_num, column_num, [abs_num], [a1], [sheet_text])", + "Builds a cell address as text from row and column numbers.", + address + ), + ] +} + +#[cfg(test)] +mod tests { + use sheetcraft_core::{CellError, Value}; + + use crate::util::testutil::*; + + fn table() -> Value { + arr(vec![ + vec![nv(1.0), tv("one"), nv(10.0)], + vec![nv(2.0), tv("two"), nv(20.0)], + vec![nv(3.0), tv("three"), nv(30.0)], + vec![nv(5.0), tv("five"), Value::Empty], + ]) + } + + fn names() -> Value { + arr(vec![vec![tv("Apple"), nv(1.0)], vec![tv("Banana"), nv(2.0)], vec![tv("Cherry"), nv(3.0)]]) + } + + #[test] + fn vlookup() { + is_text(ev("VLOOKUP", vec![n(2.0), rf(table()), n(2.0)]), "two"); + is_text(ev("VLOOKUP", vec![n(4.0), rf(table()), n(2.0)]), "three"); + is_text(ev("VLOOKUP", vec![n(4.0), rf(table()), n(2.0), b(true)]), "three"); + is_text(ev("VLOOKUP", vec![n(99.0), rf(table()), n(2.0)]), "five"); + is_err(ev("VLOOKUP", vec![n(0.5), rf(table()), n(2.0)]), CellError::NA); + is_err(ev("VLOOKUP", vec![n(4.0), rf(table()), n(2.0), b(false)]), CellError::NA); + is_err(ev("VLOOKUP", vec![n(4.0), rf(table()), n(2.0), empty()]), CellError::NA); + close(ev("VLOOKUP", vec![n(3.0), rf(table()), n(3.0), b(false)]), 30.0); + close(ev("VLOOKUP", vec![n(5.0), rf(table()), n(3.0), b(false)]), 0.0); + is_err(ev("VLOOKUP", vec![n(3.0), rf(table()), n(4.0)]), CellError::Ref); + is_err(ev("VLOOKUP", vec![n(3.0), rf(table()), n(0.0)]), CellError::Value); + close(ev("VLOOKUP", vec![t("b*"), rf(names()), n(2.0), b(false)]), 2.0); + close(ev("VLOOKUP", vec![t("cherry"), rf(names()), n(2.0), b(false)]), 3.0); + close(ev("VLOOKUP", vec![t("?pple"), rf(names()), n(2.0), b(false)]), 1.0); + close(ev("VLOOKUP", vec![t("Bz"), rf(names()), n(2.0)]), 2.0); + is_err(ev("VLOOKUP", vec![e(CellError::Div0), rf(names()), n(2.0)]), CellError::Div0); + // Lifting over the lookup value. + let r = ev("VLOOKUP", vec![av(col(&[1.0, 3.0])), rf(table()), n(3.0), b(false)]); + assert_eq!(rows_of(&r), vec![vec![nv(10.0)], vec![nv(30.0)]]); + } + + #[test] + fn hlookup() { + let t2 = arr(vec![vec![nv(1.0), nv(2.0), nv(3.0)], vec![tv("a"), tv("b"), tv("c")]]); + is_text(ev("HLOOKUP", vec![n(2.0), rf(t2.clone()), n(2.0), b(false)]), "b"); + is_text(ev("HLOOKUP", vec![n(2.5), rf(t2.clone()), n(2.0)]), "b"); + is_err(ev("HLOOKUP", vec![n(2.0), rf(t2), n(3.0)]), CellError::Ref); + } + + #[test] + fn lookup_fn() { + close(ev("LOOKUP", vec![n(4.0), av(row(&[1.0, 3.0, 5.0])), av(row(&[10.0, 30.0, 50.0]))]), 30.0); + close(ev("LOOKUP", vec![n(5.0), av(row(&[1.0, 3.0, 5.0])), av(col(&[10.0, 30.0, 50.0]))]), 50.0); + is_err(ev("LOOKUP", vec![n(0.0), av(row(&[1.0, 3.0, 5.0]))]), CellError::NA); + close(ev("LOOKUP", vec![n(2.5), rf(table())]), 20.0); + let wide = arr(vec![vec![nv(1.0), nv(2.0), nv(3.0)], vec![tv("a"), tv("b"), tv("c")]]); + is_text(ev("LOOKUP", vec![n(2.0), av(wide)]), "b"); + // Text mixed in is skipped. + let mixed = arr(vec![vec![nv(1.0), tv("x"), nv(3.0), tv("y")]]); + close(ev("LOOKUP", vec![n(9.0), av(mixed.clone()), av(row(&[1.0, 2.0, 3.0, 4.0]))]), 3.0); + close(ev("LOOKUP", vec![t("zz"), av(mixed), av(row(&[1.0, 2.0, 3.0, 4.0]))]), 4.0); + } + + #[test] + fn match_fn() { + let v = col(&[10.0, 20.0, 30.0, 40.0]); + close(ev("MATCH", vec![n(30.0), rf(v.clone()), n(0.0)]), 3.0); + close(ev("MATCH", vec![n(35.0), rf(v.clone())]), 3.0); + close(ev("MATCH", vec![n(35.0), rf(v.clone()), n(1.0)]), 3.0); + close(ev("MATCH", vec![n(100.0), rf(v.clone())]), 4.0); + is_err(ev("MATCH", vec![n(5.0), rf(v.clone())]), CellError::NA); + is_err(ev("MATCH", vec![n(35.0), rf(v), n(0.0)]), CellError::NA); + let d = col(&[40.0, 30.0, 20.0, 10.0]); + close(ev("MATCH", vec![n(25.0), rf(d.clone()), n(-1.0)]), 2.0); + close(ev("MATCH", vec![n(40.0), rf(d.clone()), n(-1.0)]), 1.0); + is_err(ev("MATCH", vec![n(50.0), rf(d), n(-1.0)]), CellError::NA); + let s = arr(vec![vec![tv("apple"), tv("banana"), tv("cherry")]]); + close(ev("MATCH", vec![t("B*"), av(s.clone()), n(0.0)]), 2.0); + close(ev("MATCH", vec![t("CHERRY"), av(s.clone()), n(0.0)]), 3.0); + close(ev("MATCH", vec![t("c"), av(s.clone())]), 2.0); + is_err(ev("MATCH", vec![n(1.0), rf(table())]), CellError::NA); + close(ev("MATCH", vec![b(true), av(arr(vec![vec![nv(1.0), Value::Bool(true)]])), n(0.0)]), 2.0); + } + + #[test] + fn xlookup() { + let keys = col(&[1.0, 2.0, 3.0, 5.0]); + let vals = col(&[10.0, 20.0, 30.0, 50.0]); + close(ev("XLOOKUP", vec![n(3.0), rf(keys.clone()), rf(vals.clone())]), 30.0); + is_err(ev("XLOOKUP", vec![n(4.0), rf(keys.clone()), rf(vals.clone())]), CellError::NA); + is_text(ev("XLOOKUP", vec![n(4.0), rf(keys.clone()), rf(vals.clone()), t("none")]), "none"); + close(ev("XLOOKUP", vec![n(4.0), rf(keys.clone()), rf(vals.clone()), empty(), n(-1.0)]), 30.0); + close(ev("XLOOKUP", vec![n(4.0), rf(keys.clone()), rf(vals.clone()), empty(), n(1.0)]), 50.0); + is_err(ev("XLOOKUP", vec![n(6.0), rf(keys.clone()), rf(vals.clone()), empty(), n(1.0)]), CellError::NA); + close(ev("XLOOKUP", vec![n(4.0), rf(keys.clone()), rf(vals.clone()), empty(), n(-1.0), n(2.0)]), 30.0); + close(ev("XLOOKUP", vec![n(4.0), rf(keys.clone()), rf(vals.clone()), empty(), n(1.0), n(2.0)]), 50.0); + close(ev("XLOOKUP", vec![n(5.0), rf(keys.clone()), rf(vals.clone()), empty(), n(0.0), n(2.0)]), 50.0); + let dup = col(&[1.0, 2.0, 1.0]); + let dv = col(&[10.0, 20.0, 30.0]); + close(ev("XLOOKUP", vec![n(1.0), rf(dup.clone()), rf(dv.clone())]), 10.0); + close(ev("XLOOKUP", vec![n(1.0), rf(dup), rf(dv), empty(), n(0.0), n(-1.0)]), 30.0); + // Descending binary search. + let desc = col(&[50.0, 30.0, 20.0, 10.0]); + close(ev("XLOOKUP", vec![n(25.0), rf(desc.clone()), rf(col(&[1.0, 2.0, 3.0, 4.0])), empty(), n(-1.0), n(-2.0)]), 3.0); + close(ev("XLOOKUP", vec![n(25.0), rf(desc), rf(col(&[1.0, 2.0, 3.0, 4.0])), empty(), n(1.0), n(-2.0)]), 2.0); + // Wildcards only in mode 2. + let names_col = arr(vec![vec![tv("Apple")], vec![tv("Banana")]]); + is_err(ev("XLOOKUP", vec![t("b*"), rf(names_col.clone()), rf(col(&[1.0, 2.0]))]), CellError::NA); + close(ev("XLOOKUP", vec![t("b*"), rf(names_col), rf(col(&[1.0, 2.0])), empty(), n(2.0)]), 2.0); + // Returning a whole row. + let r = ev("XLOOKUP", vec![n(2.0), rf(col(&[1.0, 2.0, 3.0])), rf(table_rows3())]); + assert_eq!(rows_of(&r), vec![vec![nv(2.0), tv("two"), nv(20.0)]]); + // Horizontal lookup returning a column. + let hk = row(&[1.0, 2.0]); + let hv = arr(vec![vec![tv("a"), tv("b")], vec![tv("c"), tv("d")]]); + assert_eq!(rows_of(&ev("XLOOKUP", vec![n(2.0), av(hk.clone()), av(hv)])), vec![vec![tv("b")], vec![tv("d")]]); + is_err(ev("XLOOKUP", vec![n(2.0), av(hk), av(col(&[1.0, 2.0, 3.0]))]), CellError::Value); + is_err(ev("XLOOKUP", vec![n(2.0), rf(keys.clone()), rf(vals.clone()), empty(), n(3.0)]), CellError::Value); + is_err(ev("XLOOKUP", vec![n(2.0), rf(table()), rf(vals)]), CellError::Value); + } + + fn table_rows3() -> Value { + arr(vec![vec![nv(1.0), tv("one"), nv(10.0)], vec![nv(2.0), tv("two"), nv(20.0)], vec![nv(3.0), tv("three"), nv(30.0)]]) + } + + #[test] + fn xmatch() { + let v = col(&[10.0, 20.0, 30.0]); + close(ev("XMATCH", vec![n(20.0), rf(v.clone())]), 2.0); + is_err(ev("XMATCH", vec![n(25.0), rf(v.clone())]), CellError::NA); + close(ev("XMATCH", vec![n(25.0), rf(v.clone()), n(1.0)]), 3.0); + close(ev("XMATCH", vec![n(25.0), rf(v.clone()), n(-1.0)]), 2.0); + close(ev("XMATCH", vec![n(30.0), rf(v.clone()), n(0.0), n(2.0)]), 3.0); + close(ev("XMATCH", vec![t("a?c"), av(arr(vec![vec![tv("xyz"), tv("abc")]])), n(2.0)]), 2.0); + let r = ev("XMATCH", vec![av(row(&[30.0, 10.0])), rf(v)]); + assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]); + } + + #[test] + fn index() { + let t3 = table_rows3(); + is_text(ev("INDEX", vec![rf(t3.clone()), n(2.0), n(2.0)]), "two"); + close(ev("INDEX", vec![rf(t3.clone()), n(3.0), n(3.0)]), 30.0); + assert_eq!(rows_of(&ev("INDEX", vec![rf(t3.clone()), n(0.0), n(3.0)])), vec![vec![nv(10.0)], vec![nv(20.0)], vec![nv(30.0)]]); + assert_eq!(rows_of(&ev("INDEX", vec![rf(t3.clone()), n(1.0), n(0.0)])), vec![vec![nv(1.0), tv("one"), nv(10.0)]]); + assert_eq!(rows_of(&ev("INDEX", vec![rf(t3.clone()), n(1.0)])), vec![vec![nv(1.0), tv("one"), nv(10.0)]]); + is_err(ev("INDEX", vec![rf(t3.clone()), n(4.0), n(1.0)]), CellError::Ref); + is_err(ev("INDEX", vec![rf(t3.clone()), n(1.0), n(4.0)]), CellError::Ref); + is_err(ev("INDEX", vec![rf(t3), n(-1.0), n(1.0)]), CellError::Value); + close(ev("INDEX", vec![av(row(&[5.0, 6.0, 7.0])), n(2.0)]), 6.0); + close(ev("INDEX", vec![av(col(&[5.0, 6.0, 7.0])), n(3.0)]), 7.0); + close(ev("INDEX", vec![n(9.0), n(1.0), n(1.0)]), 9.0); + let r = ev("INDEX", vec![av(col(&[5.0, 6.0, 7.0])), av(row(&[3.0, 1.0]))]); + assert_eq!(rows_of(&r), vec![vec![nv(7.0), nv(5.0)]]); + } + + #[test] + fn shape_and_address() { + close(ev("ROWS", vec![rf(table())]), 4.0); + close(ev("COLUMNS", vec![rf(table())]), 3.0); + close(ev("ROWS", vec![n(1.0)]), 1.0); + let tr = ev("TRANSPOSE", vec![av(row(&[1.0, 2.0, 3.0]))]); + assert_eq!(rows_of(&tr), vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(3.0)]]); + is_text(ev("ADDRESS", vec![n(1.0), n(1.0)]), "$A$1"); + is_text(ev("ADDRESS", vec![n(2.0), n(3.0), n(2.0)]), "C$2"); + is_text(ev("ADDRESS", vec![n(2.0), n(3.0), n(3.0)]), "$C2"); + is_text(ev("ADDRESS", vec![n(2.0), n(28.0), n(4.0)]), "AB2"); + is_text(ev("ADDRESS", vec![n(2.0), n(3.0), n(1.0), b(false)]), "R2C3"); + is_text(ev("ADDRESS", vec![n(2.0), n(3.0), n(4.0), b(false)]), "R[2]C[3]"); + is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t("Sheet1")]), "Sheet1!$A$1"); + is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t("My Sheet")]), "'My Sheet'!$A$1"); + is_text(ev("ADDRESS", vec![n(1048576.0), n(16384.0)]), "$XFD$1048576"); + is_err(ev("ADDRESS", vec![n(0.0), n(1.0)]), CellError::Value); + is_err(ev("ADDRESS", vec![n(1.0), n(16385.0)]), CellError::Value); + is_err(ev("ADDRESS", vec![n(1.0), n(1.0), n(5.0)]), CellError::Value); + } +} diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs new file mode 100644 index 00000000..5f4bab42 --- /dev/null +++ b/crates/functions/src/math.rs @@ -0,0 +1,1587 @@ +//! Math & trigonometry functions, plus the criteria aggregates (SUMIF(S), COUNTIF(S), +//! AVERAGEIF(S), MAXIFS, MINIFS). + +use std::f64::consts::PI; + +use sheetcraft_core::{Array, CellError, Value}; + +use crate::criteria::Criterion; +use crate::util::{ + A, MAX_CELLS, R, S, array_val, as_array, check_size, has, num, num_val, numbers, opt_num, round_half_away, text, text_val, to_sig_digits, +}; +use crate::{Arg, Ctx, FnSpec, VAR}; + +// --------------------------------------------------------------------------------------------- +// Lifting tables + +const fn alt_table(first_crit: usize) -> [bool; 256] { + // Criteria positions: first_crit, first_crit + 2, … + let mut t = [false; 256]; + let mut i = first_crit; + while i < 256 { + t[i] = true; + i += 2; + } + t +} +/// COUNTIFS(range, crit, range, crit, …) +static COUNTIFS_SCAL: [bool; 256] = alt_table(1); +/// SUMIFS(sum_range, range, crit, …) +static SUMIFS_SCAL: [bool; 256] = alt_table(2); +const IF2: &[bool] = &[false, true, false]; + +// --------------------------------------------------------------------------------------------- +// Small helpers + +const TRIG_LIMIT: f64 = 134_217_728.0; // 2^27: Excel's argument limit for trig functions + +fn unary(a: &[Arg], f: impl Fn(f64) -> R) -> R { + num_val(f(num(a, 0)?)?) +} + +fn trig_arg(x: f64) -> R { + if x.abs() >= TRIG_LIMIT { Err(CellError::Num) } else { Ok(x) } +} + +fn nonzero(x: f64) -> R { + if x == 0.0 { Err(CellError::Div0) } else { Ok(x) } +} + +/// Removes binary noise before ceil/floor (e.g. 2.0000000000000004 → 2). +fn clean(x: f64) -> f64 { + to_sig_digits(x, 15) +} + +fn digits_arg(a: &[Arg], i: usize) -> R { + let d = opt_num(a, i, 0.0)?.trunc(); + Ok(d.clamp(-400.0, 400.0) as i32) +} + +/// Rounds |x| scaled by 10^digits with `op` (ceil / floor), keeping sign. +fn round_dir(x: f64, digits: i32, up: bool) -> f64 { + if x == 0.0 || !x.is_finite() { + return x; + } + let ax = clean(x.abs()); + let (scaled, f, neg) = if digits >= 0 { + let f = 10f64.powi(digits); + (ax * f, f, false) + } else { + let f = 10f64.powi(-digits); + (ax / f, f, true) + }; + if !scaled.is_finite() || scaled >= 1e17 { + return x; + } + let s = clean(scaled); + let r = if up { s.ceil() } else { s.floor() }; + let out = if neg { r * f } else { clean(r / f) }; + if x < 0.0 { -out } else { out } +} + +// --------------------------------------------------------------------------------------------- +// Sums and products + +fn sum(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(numbers(a)?.iter().sum()) +} + +fn sumsq(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(numbers(a)?.iter().map(|x| x * x).sum()) +} + +fn product(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = numbers(a)?; + if v.is_empty() { + return Ok(Value::Number(0.0)); + } + num_val(v.iter().product()) +} + +fn sumproduct(a: &[Arg], _c: &mut dyn Ctx) -> R { + let arrays: Vec<_> = a.iter().map(|x| as_array(&x.value)).collect(); + let Some(first) = arrays.first() else { return Err(CellError::Value) }; + let (rows, cols) = (first.rows, first.cols); + if arrays.iter().any(|x| x.rows != rows || x.cols != cols) { + return Err(CellError::Value); + } + let mut total = 0.0; + for i in 0..rows.saturating_mul(cols) { + let mut p = 1.0; + for arr in &arrays { + match arr.data.get(i) { + Some(Value::Number(n)) => p *= n, + Some(Value::Error(e)) => return Err(*e), + _ => p = 0.0, + } + } + total += p; + } + num_val(total) +} + +fn pair_sum(a: &[Arg], f: fn(f64, f64) -> f64) -> R { + let x = as_array(&crate::util::arg(a, 0)?.value); + let y = as_array(&crate::util::arg(a, 1)?.value); + if x.data.len() != y.data.len() { + return Err(CellError::NA); + } + let mut total = 0.0; + let mut any = false; + for (u, v) in x.data.iter().zip(y.data.iter()) { + if let Value::Error(e) = u { + return Err(*e); + } + if let Value::Error(e) = v { + return Err(*e); + } + if let (Value::Number(p), Value::Number(q)) = (u, v) { + total += f(*p, *q); + any = true; + } + } + if !any { + return Err(CellError::Div0); + } + num_val(total) +} + +fn sumx2my2(a: &[Arg], _c: &mut dyn Ctx) -> R { + pair_sum(a, |x, y| x * x - y * y) +} +fn sumx2py2(a: &[Arg], _c: &mut dyn Ctx) -> R { + pair_sum(a, |x, y| x * x + y * y) +} +fn sumxmy2(a: &[Arg], _c: &mut dyn Ctx) -> R { + pair_sum(a, |x, y| (x - y) * (x - y)) +} + +// --------------------------------------------------------------------------------------------- +// Rounding + +fn round(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(round_half_away(num(a, 0)?, digits_arg(a, 1)?)) +} +fn roundup(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(round_dir(num(a, 0)?, digits_arg(a, 1)?, true)) +} +fn rounddown(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(round_dir(num(a, 0)?, digits_arg(a, 1)?, false)) +} +fn trunc(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(round_dir(num(a, 0)?, digits_arg(a, 1)?, false)) +} +fn int_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x.floor())) +} + +fn mround(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, m) = (num(a, 0)?, num(a, 1)?); + if m == 0.0 || x == 0.0 { + return Ok(Value::Number(0.0)); + } + if (x > 0.0) != (m > 0.0) { + return Err(CellError::Num); + } + let q = round_half_away(x / m, 0); + num_val(clean(q * m)) +} + +fn ceiling(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let s = num(a, 1)?; + if x == 0.0 || s == 0.0 { + return Ok(Value::Number(0.0)); + } + if x > 0.0 && s < 0.0 { + return Err(CellError::Num); + } + num_val(clean(clean(x / s).ceil() * s)) +} + +fn floor(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let s = num(a, 1)?; + if s == 0.0 { + return if x == 0.0 { Ok(Value::Number(0.0)) } else { Err(CellError::Div0) }; + } + if x == 0.0 { + return Ok(Value::Number(0.0)); + } + if x > 0.0 && s < 0.0 { + return Err(CellError::Num); + } + num_val(clean(clean(x / s).floor() * s)) +} + +/// CEILING.MATH / FLOOR.MATH / *.PRECISE: `up` picks the direction for positive numbers; +/// negative numbers round away from zero when `away`, toward zero otherwise. +fn ceil_floor_math(x: f64, s: f64, up: bool, away: bool) -> R { + let s = s.abs(); + if s == 0.0 || x == 0.0 { + return Ok(Value::Number(0.0)); + } + let q = clean(x / s); + let r = if x >= 0.0 { + if up { q.ceil() } else { q.floor() } + } else if away { + q.floor() + } else { + q.ceil() + }; + num_val(clean(r * s)) +} + +fn ceiling_math(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let s = if has(a, 1) { num(a, 1)? } else { 1.0 }; + let mode = opt_num(a, 2, 0.0)?; + ceil_floor_math(x, s, true, mode != 0.0) +} +fn floor_math(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let s = if has(a, 1) { num(a, 1)? } else { 1.0 }; + let mode = opt_num(a, 2, 0.0)?; + // Negative numbers: mode 0 rounds away from zero (down), non-zero rounds toward zero. + ceil_floor_math(x, s, false, mode == 0.0) +} +fn ceiling_precise(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let s = if has(a, 1) { num(a, 1)? } else { 1.0 }; + ceil_floor_math(x, s, true, false) +} +fn floor_precise(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let s = if has(a, 1) { num(a, 1)? } else { 1.0 }; + ceil_floor_math(x, s, false, true) +} + +fn even_odd(x: f64, odd: bool) -> f64 { + let sign = if x < 0.0 { -1.0 } else { 1.0 }; + let ax = clean(x.abs()); + let mut v = ax.ceil(); + if odd { + if v % 2.0 == 0.0 { + v += 1.0; + } + } else if v % 2.0 != 0.0 { + v += 1.0; + } + sign * v +} +fn even(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(even_odd(x, false))) +} +fn odd(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(even_odd(x, true))) +} + +// --------------------------------------------------------------------------------------------- +// Arithmetic + +fn modulo(x: f64, d: f64) -> R { + if d == 0.0 { + return Err(CellError::Div0); + } + let q = (x / d).floor(); + let r = x - d * q; + // Keep the sign of the divisor and avoid returning |d| from rounding. + if r != 0.0 && (r < 0.0) != (d < 0.0) { + return Ok(r + d); + } + if r.abs() >= d.abs() { + return Ok(0.0); + } + Ok(clean(r)) +} +fn mod_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(modulo(num(a, 0)?, num(a, 1)?)?) +} +fn quotient(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, d) = (num(a, 0)?, num(a, 1)?); + num_val(clean(x / nonzero(d)?).trunc()) +} +fn abs(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x.abs())) +} +fn sign(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| { + Ok(if x > 0.0 { + 1.0 + } else if x < 0.0 { + -1.0 + } else { + 0.0 + }) + }) +} +fn sqrt(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| if x < 0.0 { Err(CellError::Num) } else { Ok(x.sqrt()) }) +} +fn sqrtpi(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| if x < 0.0 { Err(CellError::Num) } else { Ok((x * PI).sqrt()) }) +} + +fn power_of(x: f64, y: f64) -> R { + if x == 0.0 { + if y == 0.0 { + return Err(CellError::Num); + } + if y < 0.0 { + return Err(CellError::Div0); + } + return Ok(0.0); + } + if x < 0.0 && y.fract() != 0.0 { + return Err(CellError::Num); + } + let r = x.powf(y); + if r.is_finite() { Ok(r) } else { Err(CellError::Num) } +} +fn power(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(power_of(num(a, 0)?, num(a, 1)?)?) +} +fn exp(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x.exp())) +} +fn ln(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| if x <= 0.0 { Err(CellError::Num) } else { Ok(x.ln()) }) +} +fn log10(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| if x <= 0.0 { Err(CellError::Num) } else { Ok(x.log10()) }) +} +fn log(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let base = opt_num(a, 1, 10.0)?; + if x <= 0.0 || base <= 0.0 { + return Err(CellError::Num); + } + if base == 1.0 { + return Err(CellError::Div0); + } + let r = if base == 10.0 { + x.log10() + } else if base == 2.0 { + x.log2() + } else { + x.ln() / base.ln() + }; + num_val(r) +} +fn pi(_a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(PI) +} +fn rand(_a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(c.random()) +} +fn randbetween(a: &[Arg], c: &mut dyn Ctx) -> R { + let lo = num(a, 0)?.ceil(); + let hi = num(a, 1)?.floor(); + if lo > hi { + return Err(CellError::Num); + } + let r = c.random().clamp(0.0, 1.0); + let v = (lo + (r * (hi - lo + 1.0)).floor()).min(hi); + num_val(v) +} + +// --------------------------------------------------------------------------------------------- +// Combinatorics + +fn fact_of(n: f64) -> R { + let n = n.trunc(); + if n < 0.0 { + return Err(CellError::Num); + } + if n > 170.0 { + return Err(CellError::Num); + } + let mut f = 1.0f64; + let mut i = 2.0; + while i <= n { + f *= i; + i += 1.0; + } + Ok(f) +} +fn fact(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(fact_of(num(a, 0)?)?) +} +fn factdouble(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + if n < -1.0 { + return Err(CellError::Num); + } + if n > 300.0 { + return Err(CellError::Num); + } + let mut f = 1.0f64; + let mut i = n; + while i > 1.0 { + f *= i; + i -= 2.0; + } + num_val(f) +} + +fn combin_of(n: f64, k: f64) -> R { + let (n, k) = (n.trunc(), k.trunc()); + if n < 0.0 || k < 0.0 || k > n { + return Err(CellError::Num); + } + let k = k.min(n - k); + if k > 1e7 { + let r = (crate::special::ln_gamma(n + 1.0) - crate::special::ln_gamma(k + 1.0) - crate::special::ln_gamma(n - k + 1.0)).exp(); + return if r.is_finite() { Ok(r) } else { Err(CellError::Num) }; + } + let mut r = 1.0f64; + let mut i = 1.0; + while i <= k { + r = r * (n - k + i) / i; + if !r.is_finite() { + return Err(CellError::Num); + } + i += 1.0; + } + Ok(if r < 9e15 { r.round() } else { r }) +} +fn combin(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(combin_of(num(a, 0)?, num(a, 1)?)?) +} +fn combina(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (n, k) = (num(a, 0)?.trunc(), num(a, 1)?.trunc()); + if n < 0.0 || k < 0.0 || n < k && n == 0.0 && k > 0.0 { + return Err(CellError::Num); + } + if k == 0.0 { + return Ok(Value::Number(1.0)); + } + num_val(combin_of(n + k - 1.0, k)?) +} +fn permut(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (n, k) = (num(a, 0)?.trunc(), num(a, 1)?.trunc()); + if n < 0.0 || k < 0.0 || k > n { + return Err(CellError::Num); + } + let mut r = 1.0f64; + let mut i = 0.0; + while i < k { + r *= n - i; + if !r.is_finite() { + return Err(CellError::Num); + } + i += 1.0; + } + num_val(r) +} +fn permutationa(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (n, k) = (num(a, 0)?.trunc(), num(a, 1)?.trunc()); + if n < 0.0 || k < 0.0 { + return Err(CellError::Num); + } + num_val(n.powf(k)) +} + +fn integer_args(a: &[Arg]) -> R> { + let v = numbers(a)?; + v.iter() + .map(|x| { + let t = x.trunc(); + if t < 0.0 || t >= 9.007_199_254_740_992e15 { Err(CellError::Num) } else { Ok(t as u64) } + }) + .collect() +} +fn gcd2(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + let t = a % b; + a = b; + b = t; + } + a +} +fn gcd(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = integer_args(a)?; + num_val(v.iter().fold(0u64, |acc, &x| gcd2(acc, x)) as f64) +} +fn lcm(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = integer_args(a)?; + let mut acc = 1f64; + for &x in &v { + if x == 0 { + return Ok(Value::Number(0.0)); + } + let accu = acc as u64; + let g = gcd2(accu, x); + acc = acc / g as f64 * x as f64; + if acc >= 9.007_199_254_740_992e15 { + return Err(CellError::Num); + } + } + num_val(acc) +} +fn multinomial(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = numbers(a)?; + let mut total = 0.0; + let mut r = 1.0f64; + for x in v { + let k = x.trunc(); + if k < 0.0 { + return Err(CellError::Num); + } + total += k; + r *= combin_of(total, k)?; + if !r.is_finite() { + return Err(CellError::Num); + } + } + num_val(r) +} + +// --------------------------------------------------------------------------------------------- +// Trigonometry + +fn sin(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(trig_arg(x)?.sin())) +} +fn cos(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(trig_arg(x)?.cos())) +} +fn tan(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(trig_arg(x)?.tan())) +} +fn asin(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| if x.abs() > 1.0 { Err(CellError::Num) } else { Ok(x.asin()) }) +} +fn acos(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| if x.abs() > 1.0 { Err(CellError::Num) } else { Ok(x.acos()) }) +} +fn atan(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x.atan())) +} +fn atan2(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, y) = (num(a, 0)?, num(a, 1)?); + if x == 0.0 && y == 0.0 { + return Err(CellError::Div0); + } + num_val(y.atan2(x)) +} +fn sinh(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x.sinh())) +} +fn cosh(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x.cosh())) +} +fn tanh(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x.tanh())) +} +fn asinh(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x.asinh())) +} +fn acosh(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| if x < 1.0 { Err(CellError::Num) } else { Ok(x.acosh()) }) +} +fn atanh(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| if x.abs() >= 1.0 { Err(CellError::Num) } else { Ok(x.atanh()) }) +} +fn cot(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(1.0 / nonzero(trig_arg(x)?)?.tan())) +} +fn coth(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(1.0 / nonzero(trig_arg(x)?)?.tanh())) +} +fn csc(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(1.0 / nonzero(trig_arg(x)?)?.sin())) +} +fn csch(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(1.0 / nonzero(trig_arg(x)?)?.sinh())) +} +fn sec(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(1.0 / trig_arg(x)?.cos())) +} +fn sech(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(1.0 / trig_arg(x)?.cosh())) +} +fn acot(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(PI / 2.0 - x.atan())) +} +fn acoth(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| if x.abs() <= 1.0 { Err(CellError::Num) } else { Ok(0.5 * ((x + 1.0) / (x - 1.0)).ln()) }) +} +fn degrees(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x * 180.0 / PI)) +} +fn radians(a: &[Arg], _c: &mut dyn Ctx) -> R { + unary(a, |x| Ok(x * PI / 180.0)) +} + +// --------------------------------------------------------------------------------------------- +// Number systems + +const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)]; + +/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; higher forms +/// allow subtracting progressively smaller numerals (max ratio 10, 20, 50, 100, 1000). +fn roman_tokens(form: u32) -> Vec<(String, u32)> { + let max_ratio = [10, 20, 50, 100, 1000].get(form as usize).copied().unwrap_or(10); + let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect(); + for (i, (sc, sv)) in NUMERALS.iter().enumerate() { + for (lc, lv) in NUMERALS.iter().skip(i + 1) { + let ratio = lv / sv; + let pow10 = matches!(sc, 'I' | 'X' | 'C'); + let ok = if form == 0 { pow10 && ratio <= 10 } else { ratio <= max_ratio }; + let value = lv - sv; + // Skip pairs that equal a single numeral (VX = V) – they are never shorter. + if ok && !NUMERALS.iter().any(|(_, v)| *v == value) { + toks.push((format!("{sc}{lc}"), value)); + } + } + } + // Largest value first; on ties prefer the shorter / classic token. + toks.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.len().cmp(&b.0.len()))); + toks +} + +fn roman(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + let form = match a.get(1).map(|x| x.value.scalar()) { + None => 0.0, + Some(Value::Bool(true)) => 0.0, + Some(Value::Bool(false)) => 4.0, + Some(_) => num(a, 1)?.trunc(), + }; + if !(0.0..=3999.0).contains(&n) || !(0.0..=4.0).contains(&form) { + return Err(CellError::Value); + } + let toks = roman_tokens(form as u32); + let mut rem = n as u32; + let mut out = String::new(); + while rem > 0 { + let Some((t, v)) = toks.iter().find(|(_, v)| *v <= rem) else { break }; + out.push_str(t); + rem -= v; + } + text_val(out) +} + +fn arabic(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let s = s.trim(); + if s.chars().count() > 255 { + return Err(CellError::Value); + } + let (neg, body) = match s.strip_prefix('-') { + Some(r) => (true, r), + None => (false, s), + }; + let mut vals = Vec::new(); + for ch in body.chars() { + let u = ch.to_ascii_uppercase(); + let Some((_, v)) = NUMERALS.iter().find(|(c, _)| *c == u) else { return Err(CellError::Value) }; + vals.push(*v as i64); + } + let mut total = 0i64; + for (i, v) in vals.iter().enumerate() { + if vals.get(i + 1).is_some_and(|next| next > v) { + total -= v; + } else { + total += v; + } + } + num_val(if neg { -total as f64 } else { total as f64 }) +} + +fn base(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + let radix = num(a, 1)?.trunc(); + let min_len = opt_num(a, 2, 0.0)?.trunc(); + if !(0.0..9.007_199_254_740_992e15).contains(&n) || !(2.0..=36.0).contains(&radix) || !(0.0..=255.0).contains(&min_len) { + return Err(CellError::Num); + } + let radix = radix as u64; + let mut v = n as u64; + let mut digits = Vec::new(); + loop { + let d = (v % radix) as u32; + digits.push(char::from_digit(d, radix as u32).unwrap_or('0').to_ascii_uppercase()); + v /= radix; + if v == 0 { + break; + } + } + while digits.len() < min_len as usize { + digits.push('0'); + } + text_val(digits.iter().rev().collect::()) +} + +fn decimal(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let radix = num(a, 1)?.trunc(); + if !(2.0..=36.0).contains(&radix) || s.chars().count() > 255 { + return Err(CellError::Num); + } + let radix = radix as u32; + let mut total = 0f64; + for ch in s.trim().chars() { + let Some(d) = ch.to_digit(radix) else { return Err(CellError::Num) }; + total = total * radix as f64 + d as f64; + } + if total >= 9.007_199_254_740_992e15 { + return Err(CellError::Num); + } + num_val(total) +} + +// --------------------------------------------------------------------------------------------- +// Matrices + +/// A numeric matrix (every element must be a number, else `#VALUE!`). +fn matrix(v: &Value) -> R<(usize, usize, Vec)> { + let arr = as_array(v); + let mut out = Vec::with_capacity(arr.data.len()); + for x in arr.iter() { + match x { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => return Err(CellError::Value), + } + } + Ok((arr.rows, arr.cols, out)) +} + +fn to_value_array(rows: usize, cols: usize, data: Vec) -> R { + let vals = data.into_iter().map(|x| if x.is_finite() { Value::Number(x) } else { Value::Error(CellError::Num) }).collect(); + array_val(rows, cols, vals) +} + +fn mmult(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (ar, ac, ad) = matrix(&crate::util::arg(a, 0)?.value)?; + let (br, bc, bd) = matrix(&crate::util::arg(a, 1)?.value)?; + if ac != br { + return Err(CellError::Value); + } + if ar.saturating_mul(bc) > MAX_CELLS || ar.saturating_mul(bc).saturating_mul(ac) > 2_000_000_000 { + return Err(CellError::Num); + } + let mut out = vec![0.0; ar * bc]; + for i in 0..ar { + for k in 0..ac { + let x = ad.get(i * ac + k).copied().unwrap_or(0.0); + if x == 0.0 { + continue; + } + for j in 0..bc { + if let (Some(o), Some(y)) = (out.get_mut(i * bc + j), bd.get(k * bc + j)) { + *o += x * y; + } + } + } + } + to_value_array(ar, bc, out) +} + +/// LU decomposition with partial pivoting; returns (lu, perm, sign) or None when singular. +fn square(a: &[Arg]) -> R<(usize, Vec)> { + let (r, c, d) = matrix(&crate::util::arg(a, 0)?.value)?; + if r != c { + return Err(CellError::Value); + } + if r > 2000 { + return Err(CellError::Num); + } + Ok((r, d)) +} + +fn determinant(n: usize, mut m: Vec) -> f64 { + let mut det = 1.0; + for col in 0..n { + let mut piv = col; + let mut best = 0.0; + for row in col..n { + let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); + if v > best { + best = v; + piv = row; + } + } + if best == 0.0 { + return 0.0; + } + if piv != col { + for k in 0..n { + m.swap(piv * n + k, col * n + k); + } + det = -det; + } + let p = m.get(col * n + col).copied().unwrap_or(0.0); + det *= p; + for row in col + 1..n { + let f = m.get(row * n + col).copied().unwrap_or(0.0) / p; + if f == 0.0 { + continue; + } + for k in col..n { + let v = m.get(col * n + k).copied().unwrap_or(0.0); + if let Some(x) = m.get_mut(row * n + k) { + *x -= f * v; + } + } + } + } + det +} + +fn mdeterm(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (n, m) = square(a)?; + let d = determinant(n, m); + // Tidy results that should be integers (e.g. 0.9999999999999998 → 1). + num_val(if d.abs() > 1e-300 { to_sig_digits(d, 15) } else { d }) +} + +fn minverse(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (n, mut m) = square(a)?; + let mut inv = vec![0.0; n * n]; + for i in 0..n { + if let Some(x) = inv.get_mut(i * n + i) { + *x = 1.0; + } + } + let scale = m.iter().fold(0.0f64, |acc, x| acc.max(x.abs())).max(1e-300); + for col in 0..n { + let mut piv = col; + let mut best = 0.0; + for row in col..n { + let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); + if v > best { + best = v; + piv = row; + } + } + if best <= scale * 1e-15 { + return Err(CellError::Num); + } + if piv != col { + for k in 0..n { + m.swap(piv * n + k, col * n + k); + inv.swap(piv * n + k, col * n + k); + } + } + let p = m.get(col * n + col).copied().unwrap_or(1.0); + for k in 0..n { + if let Some(x) = m.get_mut(col * n + k) { + *x /= p; + } + if let Some(x) = inv.get_mut(col * n + k) { + *x /= p; + } + } + for row in 0..n { + if row == col { + continue; + } + let f = m.get(row * n + col).copied().unwrap_or(0.0); + if f == 0.0 { + continue; + } + for k in 0..n { + let mv = m.get(col * n + k).copied().unwrap_or(0.0); + let iv = inv.get(col * n + k).copied().unwrap_or(0.0); + if let Some(x) = m.get_mut(row * n + k) { + *x -= f * mv; + } + if let Some(x) = inv.get_mut(row * n + k) { + *x -= f * iv; + } + } + } + } + let inv = inv.into_iter().map(|x| if x.abs() < 1e-15 { 0.0 } else { x }).collect(); + to_value_array(n, n, inv) +} + +fn munit(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + let (r, _) = check_size(n, n)?; + let mut data = vec![Value::Number(0.0); r * r]; + for i in 0..r { + if let Some(x) = data.get_mut(i * r + i) { + *x = Value::Number(1.0); + } + } + array_val(r, r, data) +} + +fn seriessum(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n, m) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let coeffs = as_array(&crate::util::arg(a, 3)?.value); + let mut total = 0.0; + for (i, c) in coeffs.iter().enumerate() { + let c = match c { + Value::Number(v) => *v, + Value::Error(e) => return Err(*e), + _ => return Err(CellError::Value), + }; + total += c * power_of(x, n + i as f64 * m)?; + } + num_val(total) +} + +// --------------------------------------------------------------------------------------------- +// Criteria aggregates + +/// Values at the matched positions of `target` for every (range, criteria) pair. +fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R> { + let rest = a.get(first_pair..).unwrap_or(&[]); + if rest.is_empty() || rest.len() % 2 != 0 { + return Err(CellError::Value); + } + let mut mask = vec![true; rows.saturating_mul(cols)]; + for pair in rest.chunks(2) { + let (Some(range), Some(crit)) = (pair.first(), pair.get(1)) else { return Err(CellError::Value) }; + let r = as_array(&range.value); + if r.rows != rows || r.cols != cols { + return Err(CellError::Value); + } + let c = Criterion::parse(&crit.value.scalar()); + for (m, v) in mask.iter_mut().zip(r.iter()) { + if *m && !c.matches(v) { + *m = false; + } + } + } + Ok(mask) +} + +/// Numbers in `target` where `mask` is set (errors at matched positions propagate). +fn masked_numbers(target: &Array, mask: &[bool]) -> R> { + let mut out = Vec::new(); + for (v, m) in target.iter().zip(mask.iter()) { + if !*m { + continue; + } + match v { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + } + Ok(out) +} + +fn countifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let first = as_array(&crate::util::arg(a, 0)?.value); + let mask = criteria_mask(a, 0, first.rows, first.cols)?; + num_val(mask.iter().filter(|m| **m).count() as f64) +} + +/// The values of the `*IFS` target range with the mask from its pairs. +fn ifs_numbers(a: &[Arg]) -> R> { + let target = as_array(&crate::util::arg(a, 0)?.value); + let mask = criteria_mask(a, 1, target.rows, target.cols)?; + masked_numbers(&target, &mask) +} + +fn sumifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(ifs_numbers(a)?.iter().sum()) +} +fn averageifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = ifs_numbers(a)?; + if v.is_empty() { + return Err(CellError::Div0); + } + num_val(v.iter().sum::() / v.len() as f64) +} +fn maxifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = ifs_numbers(a)?; + num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) +} +fn minifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = ifs_numbers(a)?; + num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) +} + +fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { + let range = as_array(&crate::util::arg(a, 0)?.value); + let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + num_val(range.iter().filter(|v| c.matches(v)).count() as f64) +} + +/// SUMIF / AVERAGEIF: matched numbers from the sum range, which takes the criteria range's +/// shape anchored at its own top-left. +fn if_numbers(a: &[Arg]) -> R> { + let range = as_array(&crate::util::arg(a, 0)?.value); + let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; + let mut out = Vec::new(); + for r in 0..range.rows { + for col in 0..range.cols { + let Some(v) = range.get(r, col) else { continue }; + if !c.matches(v) { + continue; + } + let target = match &sum_range { + Some(s) => s.get(r, col), + None => Some(v), + }; + match target { + Some(Value::Number(n)) => out.push(*n), + Some(Value::Error(e)) => return Err(*e), + _ => {} + } + } + } + Ok(out) +} +fn sumif(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(if_numbers(a)?.iter().sum()) +} +fn averageif(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = if_numbers(a)?; + if v.is_empty() { + return Err(CellError::Div0); + } + num_val(v.iter().sum::() / v.len() as f64) +} + +// --------------------------------------------------------------------------------------------- +// Registry + +pub(crate) fn specs() -> Vec { + vec![ + f!("SUM", 1, VAR, MathTrig, A, "SUM(number1, [number2], ...)", "Adds all the numbers in the arguments.", sum), + f!("SUMSQ", 1, VAR, MathTrig, A, "SUMSQ(number1, [number2], ...)", "Adds the squares of the numbers.", sumsq), + f!( + "SUMPRODUCT", + 1, + VAR, + MathTrig, + A, + "SUMPRODUCT(array1, [array2], ...)", + "Multiplies matching elements of equally sized arrays and adds the products.", + sumproduct + ), + f!("SUMX2MY2", 2, 2, MathTrig, A, "SUMX2MY2(array_x, array_y)", "Sums the differences of squares of matching elements.", sumx2my2), + f!("SUMX2PY2", 2, 2, MathTrig, A, "SUMX2PY2(array_x, array_y)", "Sums the sums of squares of matching elements.", sumx2py2), + f!("SUMXMY2", 2, 2, MathTrig, A, "SUMXMY2(array_x, array_y)", "Sums the squared differences of matching elements.", sumxmy2), + f!("PRODUCT", 1, VAR, MathTrig, A, "PRODUCT(number1, [number2], ...)", "Multiplies all the numbers in the arguments.", product), + f!("ROUND", 2, 2, MathTrig, S, "ROUND(number, num_digits)", "Rounds a number to a given number of digits, halves away from zero.", round), + f!("ROUNDUP", 2, 2, MathTrig, S, "ROUNDUP(number, num_digits)", "Rounds a number away from zero to a given number of digits.", roundup), + f!("ROUNDDOWN", 2, 2, MathTrig, S, "ROUNDDOWN(number, num_digits)", "Rounds a number toward zero to a given number of digits.", rounddown), + f!("MROUND", 2, 2, MathTrig, S, "MROUND(number, multiple)", "Rounds a number to the nearest multiple.", mround), + f!("CEILING", 2, 2, Compatibility, S, "CEILING(number, significance)", "Rounds a number up to a multiple of significance.", ceiling), + f!( + "CEILING.MATH", + 1, + 3, + MathTrig, + S, + "CEILING.MATH(number, [significance], [mode])", + "Rounds a number up to the nearest integer or multiple.", + ceiling_math + ), + f!( + "CEILING.PRECISE", + 1, + 2, + MathTrig, + S, + "CEILING.PRECISE(number, [significance])", + "Rounds a number toward positive infinity to a multiple.", + ceiling_precise + ), + f!( + "ISO.CEILING", + 1, + 2, + MathTrig, + S, + "ISO.CEILING(number, [significance])", + "Rounds a number toward positive infinity to a multiple.", + ceiling_precise + ), + f!("FLOOR", 2, 2, Compatibility, S, "FLOOR(number, significance)", "Rounds a number down to a multiple of significance.", floor), + f!( + "FLOOR.MATH", + 1, + 3, + MathTrig, + S, + "FLOOR.MATH(number, [significance], [mode])", + "Rounds a number down to the nearest integer or multiple.", + floor_math + ), + f!( + "FLOOR.PRECISE", + 1, + 2, + MathTrig, + S, + "FLOOR.PRECISE(number, [significance])", + "Rounds a number toward negative infinity to a multiple.", + floor_precise + ), + f!("INT", 1, 1, MathTrig, S, "INT(number)", "Rounds a number down to the nearest integer.", int_fn), + f!("TRUNC", 1, 2, MathTrig, S, "TRUNC(number, [num_digits])", "Cuts off the fractional part of a number at the given digits.", trunc), + f!("MOD", 2, 2, MathTrig, S, "MOD(number, divisor)", "Remainder of a division, with the sign of the divisor.", mod_fn), + f!("QUOTIENT", 2, 2, MathTrig, S, "QUOTIENT(numerator, denominator)", "Integer part of a division.", quotient), + f!("ABS", 1, 1, MathTrig, S, "ABS(number)", "Absolute value of a number.", abs), + f!("SIGN", 1, 1, MathTrig, S, "SIGN(number)", "1 for positive, -1 for negative and 0 for zero.", sign), + f!("SQRT", 1, 1, MathTrig, S, "SQRT(number)", "Positive square root.", sqrt), + f!("SQRTPI", 1, 1, MathTrig, S, "SQRTPI(number)", "Square root of the number times pi.", sqrtpi), + f!("POWER", 2, 2, MathTrig, S, "POWER(number, power)", "Raises a number to a power.", power), + f!("EXP", 1, 1, MathTrig, S, "EXP(number)", "e raised to the given power.", exp), + f!("LN", 1, 1, MathTrig, S, "LN(number)", "Natural logarithm.", ln), + f!("LOG", 1, 2, MathTrig, S, "LOG(number, [base])", "Logarithm to the given base (10 by default).", log), + f!("LOG10", 1, 1, MathTrig, S, "LOG10(number)", "Base-10 logarithm.", log10), + f!("PI", 0, 0, MathTrig, S, "PI()", "The constant pi to 15 digits.", pi), + f!(volatile; "RAND", 0, 0, MathTrig, S, "RAND()", "A random number between 0 and 1, recalculated every time.", rand), + f!(volatile; "RANDBETWEEN", 2, 2, MathTrig, S, "RANDBETWEEN(bottom, top)", "A random integer between two bounds, inclusive.", randbetween), + f!("FACT", 1, 1, MathTrig, S, "FACT(number)", "Factorial of a number.", fact), + f!("FACTDOUBLE", 1, 1, MathTrig, S, "FACTDOUBLE(number)", "Double factorial of a number.", factdouble), + f!("COMBIN", 2, 2, MathTrig, S, "COMBIN(number, number_chosen)", "Number of combinations without repetition.", combin), + f!("COMBINA", 2, 2, MathTrig, S, "COMBINA(number, number_chosen)", "Number of combinations with repetition.", combina), + f!("PERMUT", 2, 2, Statistical, S, "PERMUT(number, number_chosen)", "Number of ordered selections without repetition.", permut), + f!( + "PERMUTATIONA", + 2, + 2, + Statistical, + S, + "PERMUTATIONA(number, number_chosen)", + "Number of ordered selections with repetition.", + permutationa + ), + f!("GCD", 1, VAR, MathTrig, A, "GCD(number1, [number2], ...)", "Greatest common divisor of integers.", gcd), + f!("LCM", 1, VAR, MathTrig, A, "LCM(number1, [number2], ...)", "Least common multiple of integers.", lcm), + f!("EVEN", 1, 1, MathTrig, S, "EVEN(number)", "Rounds away from zero to the nearest even integer.", even), + f!("ODD", 1, 1, MathTrig, S, "ODD(number)", "Rounds away from zero to the nearest odd integer.", odd), + f!("SIN", 1, 1, MathTrig, S, "SIN(number)", "Sine of an angle in radians.", sin), + f!("COS", 1, 1, MathTrig, S, "COS(number)", "Cosine of an angle in radians.", cos), + f!("TAN", 1, 1, MathTrig, S, "TAN(number)", "Tangent of an angle in radians.", tan), + f!("ASIN", 1, 1, MathTrig, S, "ASIN(number)", "Arcsine in radians.", asin), + f!("ACOS", 1, 1, MathTrig, S, "ACOS(number)", "Arccosine in radians.", acos), + f!("ATAN", 1, 1, MathTrig, S, "ATAN(number)", "Arctangent in radians.", atan), + f!("ATAN2", 2, 2, MathTrig, S, "ATAN2(x_num, y_num)", "Angle of the point (x, y) from the x-axis in radians.", atan2), + f!("SINH", 1, 1, MathTrig, S, "SINH(number)", "Hyperbolic sine.", sinh), + f!("COSH", 1, 1, MathTrig, S, "COSH(number)", "Hyperbolic cosine.", cosh), + f!("TANH", 1, 1, MathTrig, S, "TANH(number)", "Hyperbolic tangent.", tanh), + f!("ASINH", 1, 1, MathTrig, S, "ASINH(number)", "Inverse hyperbolic sine.", asinh), + f!("ACOSH", 1, 1, MathTrig, S, "ACOSH(number)", "Inverse hyperbolic cosine.", acosh), + f!("ATANH", 1, 1, MathTrig, S, "ATANH(number)", "Inverse hyperbolic tangent.", atanh), + f!("COT", 1, 1, MathTrig, S, "COT(number)", "Cotangent of an angle in radians.", cot), + f!("COTH", 1, 1, MathTrig, S, "COTH(number)", "Hyperbolic cotangent.", coth), + f!("CSC", 1, 1, MathTrig, S, "CSC(number)", "Cosecant of an angle in radians.", csc), + f!("CSCH", 1, 1, MathTrig, S, "CSCH(number)", "Hyperbolic cosecant.", csch), + f!("SEC", 1, 1, MathTrig, S, "SEC(number)", "Secant of an angle in radians.", sec), + f!("SECH", 1, 1, MathTrig, S, "SECH(number)", "Hyperbolic secant.", sech), + f!("ACOT", 1, 1, MathTrig, S, "ACOT(number)", "Arccotangent in radians (0 to pi).", acot), + f!("ACOTH", 1, 1, MathTrig, S, "ACOTH(number)", "Inverse hyperbolic cotangent.", acoth), + f!("DEGREES", 1, 1, MathTrig, S, "DEGREES(angle)", "Converts radians to degrees.", degrees), + f!("RADIANS", 1, 1, MathTrig, S, "RADIANS(angle)", "Converts degrees to radians.", radians), + f!("ROMAN", 1, 2, MathTrig, S, "ROMAN(number, [form])", "Converts a number to Roman numerals in one of five styles.", roman), + f!("ARABIC", 1, 1, MathTrig, S, "ARABIC(text)", "Converts Roman numerals to a number.", arabic), + f!("BASE", 2, 3, MathTrig, S, "BASE(number, radix, [min_length])", "Writes a number in another base, padded with zeros.", base), + f!("DECIMAL", 2, 2, MathTrig, S, "DECIMAL(text, radix)", "Reads text in a given base as a number.", decimal), + f!("MMULT", 2, 2, MathTrig, A, "MMULT(array1, array2)", "Matrix product of two arrays.", mmult), + f!("MDETERM", 1, 1, MathTrig, A, "MDETERM(array)", "Determinant of a square matrix.", mdeterm), + f!("MINVERSE", 1, 1, MathTrig, A, "MINVERSE(array)", "Inverse of a square matrix.", minverse), + f!("MUNIT", 1, 1, MathTrig, S, "MUNIT(dimension)", "Identity matrix of the given size.", munit), + f!("SERIESSUM", 4, 4, MathTrig, &[true, true, true, false], "SERIESSUM(x, n, m, coefficients)", "Sum of a power series.", seriessum), + f!( + "MULTINOMIAL", + 1, + VAR, + MathTrig, + A, + "MULTINOMIAL(number1, [number2], ...)", + "Ratio of the factorial of a sum to the product of factorials.", + multinomial + ), + f!("SUMIF", 2, 3, MathTrig, IF2, "SUMIF(range, criteria, [sum_range])", "Adds the cells that meet a condition.", sumif), + f!( + "SUMIFS", + 3, + VAR, + MathTrig, + &SUMIFS_SCAL, + "SUMIFS(sum_range, criteria_range1, criteria1, ...)", + "Adds the cells that meet every condition.", + sumifs + ), + f!("COUNTIF", 2, 2, Statistical, NUM_RANGE_REV, "COUNTIF(range, criteria)", "Counts the cells that meet a condition.", countif), + f!( + "COUNTIFS", + 2, + VAR, + Statistical, + &COUNTIFS_SCAL, + "COUNTIFS(criteria_range1, criteria1, ...)", + "Counts the cells that meet every condition.", + countifs + ), + f!( + "AVERAGEIF", + 2, + 3, + Statistical, + IF2, + "AVERAGEIF(range, criteria, [average_range])", + "Average of the cells that meet a condition.", + averageif + ), + f!( + "AVERAGEIFS", + 3, + VAR, + Statistical, + &SUMIFS_SCAL, + "AVERAGEIFS(average_range, criteria_range1, criteria1, ...)", + "Average of the cells that meet every condition.", + averageifs + ), + f!( + "MAXIFS", + 3, + VAR, + Statistical, + &SUMIFS_SCAL, + "MAXIFS(max_range, criteria_range1, criteria1, ...)", + "Largest value among cells that meet every condition.", + maxifs + ), + f!( + "MINIFS", + 3, + VAR, + Statistical, + &SUMIFS_SCAL, + "MINIFS(min_range, criteria_range1, criteria1, ...)", + "Smallest value among cells that meet every condition.", + minifs + ), + ] +} + +const NUM_RANGE_REV: &[bool] = &[false, true]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::testutil::*; + use CellError::{Div0, NA, Num}; + + fn ev1(name: &str, x: f64) -> Value { + ev(name, vec![n(x)]) + } + fn ev2(name: &str, x: f64, y: f64) -> Value { + ev(name, vec![n(x), n(y)]) + } + + #[test] + fn sums() { + close(ev("SUM", vec![n(1.0), n(2.0), t("3"), b(true)]), 7.0); + let r = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(4.0)]]); + close(ev("SUM", vec![rf(r.clone())]), 5.0); + is_err(ev("SUM", vec![t("abc")]), CellError::Value); + is_err(ev("SUM", vec![rf(arr(vec![vec![nv(1.0), Value::Error(Div0)]]))]), Div0); + close(ev("SUM", vec![rf(tv("abc"))]), 0.0); + close(ev("SUMSQ", vec![n(3.0), n(4.0)]), 25.0); + close(ev("PRODUCT", vec![rf(r), n(2.0)]), 8.0); + close(ev("PRODUCT", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); + let a1 = arr(vec![vec![nv(3.0), nv(4.0)], vec![nv(8.0), nv(6.0)], vec![nv(1.0), nv(9.0)]]); + let a2 = arr(vec![vec![nv(2.0), nv(7.0)], vec![nv(6.0), nv(7.0)], vec![nv(5.0), nv(3.0)]]); + close(ev("SUMPRODUCT", vec![rf(a1.clone()), rf(a2)]), 156.0); + close(ev("SUMPRODUCT", vec![rf(a1.clone())]), 31.0); + is_err(ev("SUMPRODUCT", vec![rf(a1), rf(row(&[1.0, 2.0]))]), CellError::Value); + close(ev("SUMPRODUCT", vec![av(row(&[1.0, 2.0])), av(arr(vec![vec![tv("x"), nv(3.0)]]))]), 6.0); + let x = row(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); + let y = row(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); + close(ev("SUMX2MY2", vec![av(x.clone()), av(y.clone())]), -55.0); + close(ev("SUMX2PY2", vec![av(x.clone()), av(y.clone())]), 521.0); + close(ev("SUMXMY2", vec![av(x.clone()), av(y)]), 79.0); + is_err(ev("SUMXMY2", vec![av(x), av(row(&[1.0]))]), NA); + } + + #[test] + fn rounding() { + close(ev2("ROUND", 2.675, 2.0), 2.68); + close(ev2("ROUND", -1.5, 0.0), -2.0); + close(ev2("ROUND", 1234.567, -2.0), 1200.0); + close(ev2("ROUND", 2.15, 1.0), 2.2); + close(ev2("ROUNDUP", 3.2, 0.0), 4.0); + close(ev2("ROUNDUP", -3.14259, 1.0), -3.2); + close(ev2("ROUNDUP", 31415.92654, -2.0), 31500.0); + close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0); + close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719); + close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0); + close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3); + close(ev2("TRUNC", 8.987, 2.0), 8.98); + close(ev1("TRUNC", -8.9), -8.0); + close(ev1("INT", -8.9), -9.0); + close(ev1("INT", 8.9), 8.0); + close(ev2("MROUND", 10.0, 3.0), 9.0); + close(ev2("MROUND", -10.0, -3.0), -9.0); + close(ev2("MROUND", 1.3, 0.2), 1.4); + is_err(ev2("MROUND", 5.0, -2.0), Num); + close(ev2("CEILING", 2.5, 1.0), 3.0); + close(ev2("CEILING", -2.5, -2.0), -4.0); + close(ev2("CEILING", -2.5, 2.0), -2.0); + close(ev2("CEILING", 1.5, 0.1), 1.5); + close(ev2("CEILING", 0.234, 0.01), 0.24); + is_err(ev2("CEILING", 2.5, -1.0), Num); + close(ev2("FLOOR", 3.7, 2.0), 2.0); + close(ev2("FLOOR", -2.5, -2.0), -2.0); + close(ev2("FLOOR", -2.5, 2.0), -4.0); + is_err(ev2("FLOOR", 2.5, -2.0), Num); + is_err(ev2("FLOOR", 2.5, 0.0), Div0); + close(ev2("FLOOR", 1.58, 0.1), 1.5); + close(ev2("FLOOR", 0.234, 0.01), 0.23); + close(ev2("CEILING.MATH", 24.3, 5.0), 25.0); + close(ev1("CEILING.MATH", 6.7), 7.0); + close(ev2("CEILING.MATH", -8.1, 2.0), -8.0); + close(ev("CEILING.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -6.0); + close(ev2("FLOOR.MATH", 24.3, 5.0), 20.0); + close(ev1("FLOOR.MATH", 6.7), 6.0); + close(ev2("FLOOR.MATH", -8.1, 2.0), -10.0); + close(ev("FLOOR.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -4.0); + close(ev2("CEILING.PRECISE", -4.6, 2.0), -4.0); + close(ev2("CEILING.PRECISE", 4.3, -2.0), 6.0); + close(ev2("ISO.CEILING", 4.3, 0.0), 0.0); + close(ev2("FLOOR.PRECISE", -3.2, -1.0), -4.0); + close(ev1("FLOOR.PRECISE", 3.2), 3.0); + close(ev1("EVEN", 1.5), 2.0); + close(ev1("EVEN", 3.0), 4.0); + close(ev1("EVEN", 2.0), 2.0); + close(ev1("EVEN", -1.0), -2.0); + close(ev1("EVEN", 0.0), 0.0); + close(ev1("ODD", 1.5), 3.0); + close(ev1("ODD", 3.0), 3.0); + close(ev1("ODD", 2.0), 3.0); + close(ev1("ODD", -1.0), -1.0); + close(ev1("ODD", -2.0), -3.0); + close(ev1("ODD", 0.0), 1.0); + } + + #[test] + fn arithmetic() { + close(ev2("MOD", 3.0, 2.0), 1.0); + close(ev2("MOD", -3.0, 2.0), 1.0); + close(ev2("MOD", 3.0, -2.0), -1.0); + close(ev2("MOD", -3.0, -2.0), -1.0); + close(ev2("MOD", 5.5, 1.0), 0.5); + is_err(ev2("MOD", 1.0, 0.0), Div0); + close(ev2("QUOTIENT", 5.0, 2.0), 2.0); + close(ev2("QUOTIENT", -10.0, 3.0), -3.0); + is_err(ev2("QUOTIENT", 1.0, 0.0), Div0); + close(ev1("ABS", -4.0), 4.0); + close(ev1("SIGN", -0.0001), -1.0); + close(ev1("SIGN", 0.0), 0.0); + close(ev1("SQRT", 16.0), 4.0); + is_err(ev1("SQRT", -16.0), Num); + close(ev1("SQRTPI", 1.0), 1.772_453_850_905_516); + close(ev2("POWER", 5.0, 2.0), 25.0); + close(ev2("POWER", 4.0, 1.25), 5.656_854_249_492_381); + close(ev2("POWER", -2.0, 3.0), -8.0); + is_err(ev2("POWER", 0.0, 0.0), Num); + is_err(ev2("POWER", 0.0, -1.0), Div0); + is_err(ev2("POWER", -8.0, 1.0 / 3.0), Num); + is_err(ev2("POWER", 10.0, 400.0), Num); + close(ev1("EXP", 1.0), std::f64::consts::E); + close(ev2("LOG", 8.0, 2.0), 3.0); + close(ev1("LOG", 10.0), 1.0); + close(ev2("LOG", 81.0, 3.0), 4.0); + is_err(ev1("LN", 0.0), Num); + is_err(ev2("LOG", 10.0, 1.0), Div0); + is_err(ev1("LOG10", -1.0), Num); + close(ev1("LN", std::f64::consts::E), 1.0); + close(ev1("LOG10", 1e5), 5.0); + close(ev("PI", vec![]), PI); + close(ev("SQRT", vec![t("16")]), 4.0); + is_err(ev("SQRT", vec![t("x")]), CellError::Value); + } + + #[test] + fn combinatorics() { + close(ev1("FACT", 5.0), 120.0); + close(ev1("FACT", 1.9), 1.0); + close(ev1("FACT", 0.0), 1.0); + is_err(ev1("FACT", -1.0), Num); + is_err(ev1("FACT", 171.0), Num); + close(ev1("FACTDOUBLE", 6.0), 48.0); + close(ev1("FACTDOUBLE", 7.0), 105.0); + close(ev1("FACTDOUBLE", -1.0), 1.0); + is_err(ev1("FACTDOUBLE", -2.0), Num); + close(ev2("COMBIN", 8.0, 2.0), 28.0); + close(ev2("COMBIN", 8.9, 2.9), 28.0); + is_err(ev2("COMBIN", 2.0, 3.0), Num); + close(ev2("COMBIN", 60.0, 30.0), 118_264_581_564_861_424.0); + close(ev2("COMBINA", 4.0, 3.0), 20.0); + close(ev2("COMBINA", 10.0, 3.0), 220.0); + close(ev2("PERMUT", 100.0, 3.0), 970_200.0); + close(ev2("PERMUT", 3.0, 2.0), 6.0); + is_err(ev2("PERMUT", 2.0, 3.0), Num); + close(ev2("PERMUTATIONA", 3.0, 2.0), 9.0); + close(ev2("GCD", 24.0, 36.0), 12.0); + close(ev2("GCD", 5.0, 0.0), 5.0); + close(ev2("GCD", 7.0, 1.0), 1.0); + is_err(ev2("GCD", -1.0, 5.0), Num); + close(ev("GCD", vec![av(row(&[12.0, 18.0, 30.0]))]), 6.0); + close(ev2("LCM", 24.0, 36.0), 72.0); + close(ev2("LCM", 5.0, 2.0), 10.0); + close(ev2("LCM", 5.0, 0.0), 0.0); + close(ev("MULTINOMIAL", vec![n(2.0), n(3.0), n(4.0)]), 1260.0); + is_err(ev("MULTINOMIAL", vec![n(-1.0), n(2.0)]), Num); + } + + #[test] + fn trig() { + close(ev1("SIN", PI / 2.0), 1.0); + close(ev1("COS", 0.0), 1.0); + close(ev1("TAN", PI / 4.0), 1.0); + is_err(ev1("SIN", 1e10), Num); + close(ev1("ASIN", 1.0), PI / 2.0); + is_err(ev1("ACOS", 1.5), Num); + close(ev1("ACOT", 2.0), 0.463_647_609_000_806); + close(ev1("ACOTH", 6.0), 0.168_236_118_310_606); + is_err(ev1("ACOTH", 0.5), Num); + close(ev1("COT", 30.0), -0.156_119_952_161_659); + is_err(ev1("COT", 0.0), Div0); + close(ev1("COTH", 2.0), 1.037_314_720_727_548); + close(ev1("CSC", 15.0), 1.537_780_562_208_209); + close(ev1("CSCH", 1.5), 0.469_642_440_595_653); + close(ev1("SEC", 45.0), 1.903_594_407_014_6); + close_tol(ev1("SECH", 45.0), 5.725_037_161_098_787e-20, 1e-9); + close(ev2("ATAN2", 1.0, 1.0), PI / 4.0); + close(ev2("ATAN2", -1.0, -1.0), -3.0 * PI / 4.0); + is_err(ev2("ATAN2", 0.0, 0.0), Div0); + close(ev1("ASINH", -2.5), -1.647_231_146_371_096); + close(ev1("ACOSH", 10.0), 2.993_222_846_126_381); + is_err(ev1("ACOSH", 0.5), Num); + close(ev1("ATANH", 0.5), 0.549_306_144_334_055); + is_err(ev1("ATANH", 1.0), Num); + close(ev1("SINH", 1.0), 1.175_201_193_643_801); + close(ev1("DEGREES", PI), 180.0); + close(ev1("RADIANS", 270.0), 4.712_388_980_384_69); + } + + #[test] + fn number_systems() { + is_text(ev1("ROMAN", 499.0), "CDXCIX"); + is_text(ev2("ROMAN", 499.0, 1.0), "LDVLIV"); + is_text(ev2("ROMAN", 499.0, 2.0), "XDIX"); + is_text(ev2("ROMAN", 499.0, 3.0), "VDIV"); + is_text(ev2("ROMAN", 499.0, 4.0), "ID"); + is_text(ev("ROMAN", vec![n(499.0), b(false)]), "ID"); + is_text(ev1("ROMAN", 1999.0), "MCMXCIX"); + is_text(ev1("ROMAN", 3999.0), "MMMCMXCIX"); + is_text(ev1("ROMAN", 0.0), ""); + is_err(ev1("ROMAN", 4000.0), CellError::Value); + is_err(ev1("ROMAN", -1.0), CellError::Value); + close(ev("ARABIC", vec![t("LVII")]), 57.0); + close(ev("ARABIC", vec![t("mcmxii")]), 1912.0); + close(ev("ARABIC", vec![t("")]), 0.0); + close(ev("ARABIC", vec![t("-XIV")]), -14.0); + is_err(ev("ARABIC", vec![t("ABC")]), CellError::Value); + is_text(ev2("BASE", 7.0, 2.0), "111"); + is_text(ev2("BASE", 100.0, 16.0), "64"); + is_text(ev("BASE", vec![n(15.0), n(2.0), n(10.0)]), "0000001111"); + is_text(ev2("BASE", 0.0, 36.0), "0"); + is_err(ev2("BASE", 7.0, 1.0), Num); + is_err(ev2("BASE", -7.0, 2.0), Num); + close(ev("DECIMAL", vec![t("FF"), n(16.0)]), 255.0); + close(ev("DECIMAL", vec![t("111"), n(2.0)]), 7.0); + close(ev("DECIMAL", vec![t("zap"), n(36.0)]), 45745.0); + is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); + } + + #[test] + fn matrices() { + let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); + let b2 = arr(vec![vec![nv(5.0), nv(6.0)], vec![nv(7.0), nv(8.0)]]); + assert_eq!(rows_of(&ev("MMULT", vec![av(a.clone()), av(b2)])), vec![vec![nv(19.0), nv(22.0)], vec![nv(43.0), nv(50.0)]]); + is_err(ev("MMULT", vec![av(a.clone()), av(row(&[1.0, 2.0]))]), CellError::Value); + is_err(ev("MMULT", vec![av(arr(vec![vec![tv("x")]])), av(row(&[1.0]))]), CellError::Value); + close(ev("MDETERM", vec![av(a.clone())]), -2.0); + let m3 = arr(vec![vec![nv(3.0), nv(6.0), nv(1.0)], vec![nv(1.0), nv(1.0), nv(0.0)], vec![nv(3.0), nv(10.0), nv(2.0)]]); + close(ev("MDETERM", vec![av(m3)]), 1.0); + let m4 = arr(vec![ + vec![nv(1.0), nv(3.0), nv(8.0), nv(5.0)], + vec![nv(1.0), nv(3.0), nv(6.0), nv(1.0)], + vec![nv(1.0), nv(1.0), nv(1.0), nv(0.0)], + vec![nv(7.0), nv(3.0), nv(10.0), nv(2.0)], + ]); + close(ev("MDETERM", vec![av(m4)]), 88.0); + is_err(ev("MDETERM", vec![av(row(&[1.0, 2.0]))]), CellError::Value); + let inv = ev("MINVERSE", vec![av(arr(vec![vec![nv(4.0), nv(-1.0)], vec![nv(2.0), nv(0.0)]]))]); + let r = rows_of(&inv); + close(r[0][0].clone(), 0.0); + close(r[0][1].clone(), 0.5); + close(r[1][0].clone(), -1.0); + close(r[1][1].clone(), 2.0); + is_err(ev("MINVERSE", vec![av(arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(2.0), nv(4.0)]]))]), Num); + assert_eq!(rows_of(&ev1("MUNIT", 2.0)), vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)]]); + is_err(ev1("MUNIT", 0.0), CellError::Value); + is_err(ev1("MUNIT", 1e6), Num); + let coeffs = row(&[1.0, -0.5, 1.0 / 24.0, -1.0 / 720.0]); + close_tol(ev("SERIESSUM", vec![n(PI / 4.0), n(0.0), n(2.0), av(coeffs)]), 0.707_103_214_823, 1e-9); + } + + #[test] + fn random() { + let mut ctx = TestCtx::default(); + let spec = crate::lookup("RAND").unwrap(); + assert!(spec.volatile); + for _ in 0..50 { + let v = crate::call(spec, &[], &mut ctx); + let x = v.as_f64().unwrap(); + assert!((0.0..1.0).contains(&x)); + } + let spec = crate::lookup("RANDBETWEEN").unwrap(); + for _ in 0..50 { + let x = crate::call(spec, &[n(1.0), n(6.0)], &mut ctx).as_f64().unwrap(); + assert!((1.0..=6.0).contains(&x) && x.fract() == 0.0); + } + is_err(ev2("RANDBETWEEN", 5.0, 1.0), Num); + } + + #[test] + fn lifting() { + assert_eq!(rows_of(&ev("ABS", vec![av(row(&[-1.0, 2.0, -3.0]))])), vec![vec![nv(1.0), nv(2.0), nv(3.0)]]); + let v = ev("POWER", vec![av(col(&[1.0, 2.0])), av(row(&[2.0, 3.0]))]); + assert_eq!(rows_of(&v), vec![vec![nv(1.0), nv(1.0)], vec![nv(4.0), nv(8.0)]]); + close(ev("ROUND", vec![av(arr(vec![vec![nv(1.25)]])), n(1.0)]), 1.3); + } + + fn data() -> (Value, Value) { + let names = arr(vec![vec![tv("apple")], vec![tv("banana")], vec![tv("Apple")], vec![Value::Empty], vec![tv("cherry")]]); + let qty = col(&[10.0, 20.0, 30.0, 40.0, 50.0]); + (names, qty) + } + + #[test] + fn criteria_functions() { + let (names, qty) = data(); + close(ev("COUNTIF", vec![rf(names.clone()), t("apple")]), 2.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("a*")]), 2.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("<>apple")]), 3.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("")]), 1.0); + close(ev("COUNTIF", vec![rf(qty.clone()), t(">20")]), 3.0); + close(ev("COUNTIF", vec![rf(qty.clone()), n(30.0)]), 1.0); + let multi = ev("COUNTIF", vec![rf(names.clone()), av(arr(vec![vec![tv("apple"), tv("cherry")]]))]); + assert_eq!(rows_of(&multi), vec![vec![nv(2.0), nv(1.0)]]); + close(ev("SUMIF", vec![rf(names.clone()), t("apple"), rf(qty.clone())]), 40.0); + close(ev("SUMIF", vec![rf(qty.clone()), t(">=30")]), 120.0); + close(ev("SUMIF", vec![rf(names.clone()), t("?????"), rf(col(&[1.0]))]), 1.0); + close(ev("AVERAGEIF", vec![rf(qty.clone()), t(">20")]), 40.0); + is_err(ev("AVERAGEIF", vec![rf(qty.clone()), t(">100")]), Div0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple"), rf(qty.clone()), t(">15")]), 30.0); + close(ev("COUNTIFS", vec![rf(names.clone()), t("*a*"), rf(qty.clone()), t("<25")]), 2.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(names.clone()), t("<>banana")]), 32.5); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 30.0); + close(ev("MINIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 10.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("zzz")]), 0.0); + is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(row(&[1.0, 2.0])), t("1")]), CellError::Value); + is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); + is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); + } +} diff --git a/crates/functions/src/special.rs b/crates/functions/src/special.rs new file mode 100644 index 00000000..1541cd8a --- /dev/null +++ b/crates/functions/src/special.rs @@ -0,0 +1,391 @@ +//! Numeric special functions: gamma, error function, incomplete gamma and beta, the normal +//! distribution and a generic monotone root finder. Written from the standard textbook +//! formulations (Lanczos approximation, series / continued-fraction expansions). + +use std::f64::consts::PI; + +const LANCZOS_G: f64 = 7.0; +const LANCZOS: [f64; 9] = [ + 0.999_999_999_999_809_9, + 676.520_368_121_885_1, + -1_259.139_216_722_402_8, + 771.323_428_777_653_1, + -176.615_029_162_140_6, + 12.507_343_278_686_905, + -0.138_571_095_265_720_12, + 9.984_369_578_019_572e-6, + 1.505_632_735_149_311_6e-7, +]; + +/// ln |Γ(x)|. NaN at non-positive integers. +pub(crate) fn ln_gamma(x: f64) -> f64 { + if !x.is_finite() { + return f64::NAN; + } + if x <= 0.0 && x == x.floor() { + return f64::NAN; + } + if x < 0.5 { + // Reflection. + let s = (PI * x).sin().abs(); + return (PI / s).ln() - ln_gamma(1.0 - x); + } + if x < 15.0 && x == x.floor() { + // Exact for small integers. + let mut f = 1.0f64; + let mut i = 2.0; + while i < x { + f *= i; + i += 1.0; + } + return f.ln(); + } + let x = x - 1.0; + let mut a = LANCZOS[0]; + let t = x + LANCZOS_G + 0.5; + for (i, c) in LANCZOS.iter().enumerate().skip(1) { + a += c / (x + i as f64); + } + 0.5 * (2.0 * PI).ln() + (x + 0.5) * t.ln() - t + a.ln() +} + +/// Γ(x). NaN at non-positive integers, infinite on overflow. +pub(crate) fn gamma(x: f64) -> f64 { + if !x.is_finite() || (x <= 0.0 && x == x.floor()) { + return f64::NAN; + } + if x == x.floor() && x <= 171.0 { + let mut f = 1.0f64; + let mut i = 2.0; + while i < x { + f *= i; + i += 1.0; + } + return f; + } + if x < 0.5 { + return PI / ((PI * x).sin() * gamma(1.0 - x)); + } + if x > 171.7 { + return f64::INFINITY; + } + ln_gamma(x).exp() +} + +/// ln B(a, b). +pub(crate) fn ln_beta(a: f64, b: f64) -> f64 { + ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b) +} + +/// Regularised lower incomplete gamma P(a, x). +pub(crate) fn reg_gamma_p(a: f64, x: f64) -> f64 { + if a.is_nan() || a <= 0.0 || x.is_nan() || x < 0.0 || !a.is_finite() { + return f64::NAN; + } + if x == 0.0 { + return 0.0; + } + if x.is_infinite() { + return 1.0; + } + if x < a + 1.0 { gamma_series(a, x) } else { 1.0 - gamma_cf(a, x) } +} + +/// Regularised upper incomplete gamma Q(a, x) = 1 − P(a, x). +pub(crate) fn reg_gamma_q(a: f64, x: f64) -> f64 { + if a.is_nan() || a <= 0.0 || x.is_nan() || x < 0.0 || !a.is_finite() { + return f64::NAN; + } + if x == 0.0 { + return 1.0; + } + if x.is_infinite() { + return 0.0; + } + if x < a + 1.0 { 1.0 - gamma_series(a, x) } else { gamma_cf(a, x) } +} + +fn gamma_series(a: f64, x: f64) -> f64 { + let mut ap = a; + let mut sum = 1.0 / a; + let mut del = sum; + for _ in 0..10_000 { + ap += 1.0; + del *= x / ap; + sum += del; + if del.abs() < sum.abs() * 1e-17 { + break; + } + } + sum * (-x + a * x.ln() - ln_gamma(a)).exp() +} + +fn gamma_cf(a: f64, x: f64) -> f64 { + let tiny = 1e-300; + let mut b = x + 1.0 - a; + let mut c = 1.0 / tiny; + let mut d = 1.0 / b; + let mut h = d; + for i in 1..10_000 { + let an = -(i as f64) * (i as f64 - a); + b += 2.0; + d = an * d + b; + if d.abs() < tiny { + d = tiny; + } + c = b + an / c; + if c.abs() < tiny { + c = tiny; + } + d = 1.0 / d; + let del = d * c; + h *= del; + if (del - 1.0).abs() < 1e-17 { + break; + } + } + (-x + a * x.ln() - ln_gamma(a)).exp() * h +} + +/// Error function. +pub(crate) fn erf(x: f64) -> f64 { + if x.is_nan() { + return f64::NAN; + } + if x == 0.0 { + return 0.0; + } + if x.abs() < 0.5 { + // Taylor series: erf x = 2/√π Σ (-1)^n x^(2n+1) / (n! (2n+1)). + let mut sum = x; + let mut term = x; + let x2 = x * x; + for n in 1..60 { + term *= -x2 / n as f64; + let add = term / (2 * n + 1) as f64; + sum += add; + if add.abs() < 1e-18 * sum.abs() { + break; + } + } + return sum * 2.0 / PI.sqrt(); + } + let p = reg_gamma_p(0.5, x * x); + if x < 0.0 { -p } else { p } +} + +/// Complementary error function, accurate in the tails. +pub(crate) fn erfc(x: f64) -> f64 { + if x.is_nan() { + return f64::NAN; + } + if x.abs() < 0.5 { + return 1.0 - erf(x); + } + if x > 0.0 { reg_gamma_q(0.5, x * x) } else { 1.0 + reg_gamma_p(0.5, x * x) } +} + +/// Regularised incomplete beta I_x(a, b). +pub(crate) fn reg_inc_beta(x: f64, a: f64, b: f64) -> f64 { + if a.is_nan() || a <= 0.0 || b.is_nan() || b <= 0.0 || !(0.0..=1.0).contains(&x) { + return f64::NAN; + } + if x == 0.0 { + return 0.0; + } + if x == 1.0 { + return 1.0; + } + let ln_bt = -ln_beta(a, b) + a * x.ln() + b * (1.0 - x).ln(); + let bt = ln_bt.exp(); + if x < (a + 1.0) / (a + b + 2.0) { bt * beta_cf(a, b, x) / a } else { 1.0 - bt * beta_cf(b, a, 1.0 - x) / b } +} + +fn beta_cf(a: f64, b: f64, x: f64) -> f64 { + let tiny = 1e-300; + let qab = a + b; + let qap = a + 1.0; + let qam = a - 1.0; + let mut c = 1.0; + let mut d = 1.0 - qab * x / qap; + if d.abs() < tiny { + d = tiny; + } + d = 1.0 / d; + let mut h = d; + for m in 1..10_000 { + let m = m as f64; + let m2 = 2.0 * m; + let aa = m * (b - m) * x / ((qam + m2) * (a + m2)); + d = 1.0 + aa * d; + if d.abs() < tiny { + d = tiny; + } + c = 1.0 + aa / c; + if c.abs() < tiny { + c = tiny; + } + d = 1.0 / d; + h *= d * c; + let aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2)); + d = 1.0 + aa * d; + if d.abs() < tiny { + d = tiny; + } + c = 1.0 + aa / c; + if c.abs() < tiny { + c = tiny; + } + d = 1.0 / d; + let del = d * c; + h *= del; + if (del - 1.0).abs() < 1e-16 { + break; + } + } + h +} + +/// Standard normal density. +pub(crate) fn norm_pdf(z: f64) -> f64 { + (-0.5 * z * z).exp() / (2.0 * PI).sqrt() +} + +/// Standard normal cumulative distribution. +pub(crate) fn norm_cdf(z: f64) -> f64 { + 0.5 * erfc(-z / std::f64::consts::SQRT_2) +} + +/// Inverse of the standard normal CDF (NaN outside (0, 1)). +pub(crate) fn norm_inv(p: f64) -> f64 { + if !(p > 0.0 && p < 1.0) { + return f64::NAN; + } + // Acklam's rational approximation, then one Halley refinement step. + const A: [f64; 6] = [ + -3.969_683_028_665_376e1, + 2.209_460_984_245_205e2, + -2.759_285_104_469_687e2, + 1.383_577_518_672_69e2, + -3.066_479_806_614_716e1, + 2.506_628_277_459_239, + ]; + const B: [f64; 5] = + [-5.447_609_879_822_406e1, 1.615_858_368_580_409e2, -1.556_989_798_598_866e2, 6.680_131_188_771_972e1, -1.328_068_155_288_572e1]; + const C: [f64; 6] = [ + -7.784_894_002_430_293e-3, + -3.223_964_580_411_365e-1, + -2.400_758_277_161_838, + -2.549_732_539_343_734, + 4.374_664_141_464_968, + 2.938_163_982_698_783, + ]; + const D: [f64; 4] = [7.784_695_709_041_462e-3, 3.224_671_290_700_398e-1, 2.445_134_137_142_996, 3.754_408_661_907_416]; + let plow = 0.02425; + let x = if p < plow { + let q = (-2.0 * p.ln()).sqrt(); + (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0) + } else if p <= 1.0 - plow { + let q = p - 0.5; + let r = q * q; + (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q + / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0) + } else { + let q = (-2.0 * (1.0 - p).ln()).sqrt(); + -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0) + }; + let mut x = x; + for _ in 0..2 { + let e = norm_cdf(x) - p; + let u = e * (2.0 * PI).sqrt() * (x * x / 2.0).exp(); + let nx = x - u / (1.0 + x * u / 2.0); + if !nx.is_finite() { + break; + } + x = nx; + } + x +} + +/// Finds `x` in `[lo, hi]` with `f(x) = target` for a monotone `f` by bisection (expanding `hi` +/// up to `hi_max` while the target is not bracketed). Returns NaN when no root is bracketed. +pub(crate) fn invert(f: impl Fn(f64) -> f64, target: f64, lo: f64, hi: f64, hi_max: f64) -> f64 { + let (mut lo, mut hi) = (lo, hi); + let flo = f(lo) - target; + let mut fhi = f(hi) - target; + let mut guard = 0; + while flo * fhi > 0.0 && hi < hi_max && guard < 200 { + lo = hi; + hi = (hi * 2.0).min(hi_max); + fhi = f(hi) - target; + guard += 1; + } + let flo = f(lo) - target; + if flo == 0.0 { + return lo; + } + if fhi == 0.0 { + return hi; + } + if flo * fhi > 0.0 || flo.is_nan() || fhi.is_nan() { + return f64::NAN; + } + let lo_neg = flo < 0.0; + for _ in 0..300 { + let mid = 0.5 * (lo + hi); + if mid <= lo || mid >= hi { + break; + } + let fm = f(mid) - target; + if fm == 0.0 { + return mid; + } + if (fm < 0.0) == lo_neg { + lo = mid; + } else { + hi = mid; + } + if (hi - lo).abs() <= 1e-15 * mid.abs().max(1e-300) { + break; + } + } + 0.5 * (lo + hi) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: f64, b: f64, tol: f64) { + assert!((a - b).abs() <= tol * b.abs().max(1.0), "{a} vs {b}"); + } + + #[test] + fn gamma_fn() { + close(gamma(5.0), 24.0, 1e-15); + close(gamma(0.5), PI.sqrt(), 1e-13); + close(gamma(-1.5), 2.363_271_801_207_355, 1e-12); + close(ln_gamma(10.0), 12.801_827_480_081_469, 1e-13); + close(ln_gamma(101.0), 363.739_375_555_563_47, 1e-13); + assert!(gamma(0.0).is_nan()); + } + + #[test] + fn error_fn() { + close(erf(1.0), 0.842_700_792_949_714_9, 1e-14); + close(erf(0.3), 0.328_626_759_459_127_4, 1e-14); + close(erf(-2.0), -0.995_322_265_018_952_7, 1e-14); + close(erfc(3.0), 2.209_049_699_858_544e-5, 1e-12); + close(erfc(-1.0), 1.842_700_792_949_715, 1e-14); + } + + #[test] + fn incomplete() { + close(reg_gamma_p(2.0, 3.0), 0.800_851_726_528_544, 1e-13); + close(reg_inc_beta(0.4, 2.0, 3.0), 0.5248, 1e-13); + close(norm_cdf(1.96), 0.975_002_104_851_780, 1e-13); + close(norm_inv(0.975), 1.959_963_984_540_054, 1e-12); + close(norm_inv(1e-10), -6.361_340_902_404_056, 1e-10); + close(invert(|x| x * x, 2.0, 0.0, 1.0, 10.0), 2f64.sqrt(), 1e-14); + } +} diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs new file mode 100644 index 00000000..0d2eee18 --- /dev/null +++ b/crates/functions/src/stat.rs @@ -0,0 +1,2583 @@ +//! Statistical functions: aggregates, order statistics, regression and probability +//! distributions (with their inverses). + +use sheetcraft_core::{CellError, Value}; + +use crate::special::{erf, gamma, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, norm_pdf, reg_gamma_p, reg_gamma_q, reg_inc_beta}; +use crate::util::{A, R, S, arg, array_numbers, array_val, as_array, boolean, has, num, num_val, numbers, numbers_a, opt_num}; +use crate::{Arg, Ctx, FnSpec, VAR}; + +type C<'a> = &'a mut dyn Ctx; + +// --------------------------------------------------------------------------------------------- +// Basic helpers over number lists +// --------------------------------------------------------------------------------------------- + +fn mean(x: &[f64]) -> R { + if x.is_empty() { + return Err(CellError::Div0); + } + Ok(x.iter().sum::() / x.len() as f64) +} + +fn devsq(x: &[f64]) -> f64 { + if x.is_empty() { + return 0.0; + } + let m = x.iter().sum::() / x.len() as f64; + x.iter().map(|v| (v - m) * (v - m)).sum() +} + +fn var_s(x: &[f64]) -> R { + if x.len() < 2 { + return Err(CellError::Div0); + } + Ok(devsq(x) / (x.len() - 1) as f64) +} + +fn var_p(x: &[f64]) -> R { + if x.is_empty() { + return Err(CellError::Div0); + } + Ok(devsq(x) / x.len() as f64) +} + +fn sorted(mut v: Vec) -> Vec { + v.sort_by(|a, b| a.total_cmp(b)); + v +} + +fn median(v: Vec) -> R { + if v.is_empty() { + return Err(CellError::Num); + } + let s = sorted(v); + let n = s.len(); + let hi = s.get(n / 2).copied().unwrap_or(0.0); + if n % 2 == 1 { Ok(hi) } else { Ok((s.get(n / 2 - 1).copied().unwrap_or(0.0) + hi) / 2.0) } +} + +/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. +fn modes(v: &[f64]) -> Vec { + let mut counts: Vec<(f64, usize)> = Vec::new(); + for &x in v { + if let Some(e) = counts.iter_mut().find(|e| e.0 == x) { + e.1 += 1; + } else { + counts.push((x, 1)); + } + } + let best = counts.iter().map(|e| e.1).max().unwrap_or(0); + if best < 2 { + return Vec::new(); + } + counts.into_iter().filter(|e| e.1 == best).map(|e| e.0).collect() +} + +fn mode_sngl(v: &[f64]) -> R { + modes(v).first().copied().ok_or(CellError::NA) +} + +fn product(v: &[f64]) -> f64 { + if v.is_empty() { 0.0 } else { v.iter().product() } +} + +fn min_of(v: &[f64]) -> f64 { + if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::INFINITY, f64::min) } +} + +fn max_of(v: &[f64]) -> f64 { + if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::NEG_INFINITY, f64::max) } +} + +/// k-th largest (k is rounded up like Excel). +fn kth(v: Vec, k: f64, largest: bool) -> R { + let k = k.ceil(); + if k.is_nan() || k < 1.0 || k > v.len() as f64 { + return Err(CellError::Num); + } + let s = sorted(v); + let k = k as usize; + let idx = if largest { s.len() - k } else { k - 1 }; + s.get(idx).copied().ok_or(CellError::Num) +} + +fn interp(s: &[f64], pos: f64) -> R { + let i = pos.floor(); + let frac = pos - i; + let i = i as usize; + let lo = *s.get(i).ok_or(CellError::Num)?; + match s.get(i + 1) { + Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)), + _ => Ok(lo), + } +} + +fn percentile_inc(v: Vec, k: f64) -> R { + if v.is_empty() || !(0.0..=1.0).contains(&k) { + return Err(CellError::Num); + } + let s = sorted(v); + interp(&s, k * (s.len() - 1) as f64) +} + +fn percentile_exc(v: Vec, k: f64) -> R { + let n = v.len() as f64; + if v.is_empty() || !(k > 0.0 && k < 1.0) { + return Err(CellError::Num); + } + let pos = k * (n + 1.0); + if pos < 1.0 || pos > n { + return Err(CellError::Num); + } + let s = sorted(v); + interp(&s, pos - 1.0) +} + +fn quartile_inc(v: Vec, q: f64) -> R { + let q = q.trunc(); + if !(0.0..=4.0).contains(&q) { + return Err(CellError::Num); + } + percentile_inc(v, q / 4.0) +} + +fn quartile_exc(v: Vec, q: f64) -> R { + let q = q.trunc(); + if !(1.0..=3.0).contains(&q) { + return Err(CellError::Num); + } + percentile_exc(v, q / 4.0) +} + +fn arg_nums(a: &[Arg], i: usize) -> R> { + array_numbers(&arg(a, i)?.value) +} + +/// Pairs of numbers at matching positions of two arrays (pairs with a non-number skipped). +/// Different sizes give `#N/A`; errors propagate. +fn pairs(x: &Value, y: &Value) -> R<(Vec, Vec)> { + let xa = as_array(x); + let ya = as_array(y); + if xa.data.len() != ya.data.len() { + return Err(CellError::NA); + } + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for (a, b) in xa.data.iter().zip(ya.data.iter()) { + if let Value::Error(e) = a { + return Err(*e); + } + if let Value::Error(e) = b { + return Err(*e); + } + if let (Value::Number(p), Value::Number(q)) = (a, b) { + xs.push(*p); + ys.push(*q); + } + } + Ok((xs, ys)) +} + +fn arg_pairs(a: &[Arg], i: usize, j: usize) -> R<(Vec, Vec)> { + pairs(&arg(a, i)?.value, &arg(a, j)?.value) +} + +/// (sum of cross deviations, devsq x, devsq y, mean x, mean y). +fn moments(x: &[f64], y: &[f64]) -> R<(f64, f64, f64, f64, f64)> { + let mx = mean(x)?; + let my = mean(y)?; + let mut sxy = 0.0; + let mut sxx = 0.0; + let mut syy = 0.0; + for (a, b) in x.iter().zip(y) { + sxy += (a - mx) * (b - my); + sxx += (a - mx) * (a - mx); + syy += (b - my) * (b - my); + } + Ok((sxy, sxx, syy, mx, my)) +} + +fn correl(x: &[f64], y: &[f64]) -> R { + if x.len() < 2 { + return Err(CellError::Div0); + } + let (sxy, sxx, syy, _, _) = moments(x, y)?; + if sxx == 0.0 || syy == 0.0 { + return Err(CellError::Div0); + } + Ok(sxy / (sxx * syy).sqrt()) +} + +fn slope(y: &[f64], x: &[f64]) -> R { + let (sxy, sxx, _, _, _) = moments(x, y)?; + if sxx == 0.0 { + return Err(CellError::Div0); + } + Ok(sxy / sxx) +} + +fn intercept(y: &[f64], x: &[f64]) -> R { + let (sxy, sxx, _, mx, my) = moments(x, y)?; + if sxx == 0.0 { + return Err(CellError::Div0); + } + Ok(my - sxy / sxx * mx) +} + +fn trunc_digits(r: f64, sig: f64) -> f64 { + let f = 10f64.powi(sig as i32); + ((r * f) + 1e-9).floor() / f +} + +fn col_value(v: Vec) -> R { + let n = v.len(); + array_val(n, 1, v.into_iter().map(Value::Number).collect()) +} + +// --------------------------------------------------------------------------------------------- +// Aggregates +// --------------------------------------------------------------------------------------------- + +fn count_impl(a: &[Arg]) -> f64 { + let mut c = 0usize; + for x in a { + match &x.value { + Value::Array(arr) => c += arr.iter().filter(|v| v.is_number()).count(), + Value::Number(_) => c += 1, + _ if x.from_ref => {} + Value::Bool(_) => c += 1, + Value::Text(t) if sheetcraft_core::parse::parse_number_text(t).is_some() => c += 1, + _ => {} + } + } + c as f64 +} + +fn counta_impl(a: &[Arg]) -> f64 { + let mut c = 0usize; + for x in a { + match &x.value { + Value::Array(arr) => c += arr.iter().filter(|v| !v.is_empty()).count(), + Value::Empty if x.from_ref => {} + _ => c += 1, + } + } + c as f64 +} + +fn countblank(a: &[Arg], _: C) -> R { + let v = &arg(a, 0)?.value; + let blank = |x: &Value| matches!(x, Value::Empty) || matches!(x, Value::Text(t) if t.is_empty()); + let c = match v { + Value::Array(arr) => arr.iter().filter(|x| blank(x)).count(), + x => usize::from(blank(x)), + }; + num_val(c as f64) +} + +fn mode_mult(a: &[Arg], _: C) -> R { + let v = numbers(a)?; + let m = modes(&v); + if m.is_empty() { + return Err(CellError::NA); + } + col_value(m) +} + +fn large(a: &[Arg], _: C) -> R { + num_val(kth(arg_nums(a, 0)?, num(a, 1)?, true)?) +} + +fn small(a: &[Arg], _: C) -> R { + num_val(kth(arg_nums(a, 0)?, num(a, 1)?, false)?) +} + +fn rank_impl(a: &[Arg], avg: bool) -> R { + let x = num(a, 0)?; + let v = arg_nums(a, 1)?; + let asc = opt_num(a, 2, 0.0)? != 0.0; + let eq = v.iter().filter(|&&y| y == x).count(); + if eq == 0 { + return Err(CellError::NA); + } + let before = v.iter().filter(|&&y| if asc { y < x } else { y > x }).count(); + let r = before as f64 + 1.0; + num_val(if avg { r + (eq as f64 - 1.0) / 2.0 } else { r }) +} + +fn percentrank_impl(a: &[Arg], exc: bool) -> R { + let s = sorted(arg_nums(a, 0)?); + let x = num(a, 1)?; + let sig = opt_num(a, 2, 3.0)?.trunc(); + if sig < 1.0 || s.is_empty() { + return Err(CellError::Num); + } + let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0)); + if x < first || x > last { + return Err(CellError::NA); + } + let n = s.len() as f64; + let rank_of = |v: f64| -> f64 { + let less = s.iter().filter(|&&y| y < v).count() as f64; + if exc { + (less + 1.0) / (n + 1.0) + } else if n <= 1.0 { + 1.0 + } else { + less / (n - 1.0) + } + }; + let r = if s.contains(&x) { + rank_of(x) + } else { + let lo = s.iter().copied().filter(|&y| y < x).fold(f64::NEG_INFINITY, f64::max); + let hi = s.iter().copied().filter(|&y| y > x).fold(f64::INFINITY, f64::min); + let (rl, rh) = (rank_of(lo), rank_of(hi)); + rl + (x - lo) / (hi - lo) * (rh - rl) + }; + num_val(trunc_digits(r, sig)) +} + +fn trimmean(a: &[Arg], _: C) -> R { + let v = arg_nums(a, 0)?; + let p = num(a, 1)?; + if !(0.0..1.0).contains(&p) || v.is_empty() { + return Err(CellError::Num); + } + let n = v.len(); + let per_side = ((n as f64 * p) / 2.0).floor() as usize; + let s = sorted(v); + let kept = s.get(per_side..n - per_side).unwrap_or(&[]); + num_val(mean(kept)?) +} + +fn kurt(v: &[f64]) -> R { + let n = v.len() as f64; + if v.len() < 4 { + return Err(CellError::Div0); + } + let m = mean(v)?; + let s = var_s(v)?.sqrt(); + if s == 0.0 { + return Err(CellError::Div0); + } + let sum4: f64 = v.iter().map(|x| ((x - m) / s).powi(4)).sum(); + Ok(n * (n + 1.0) / ((n - 1.0) * (n - 2.0) * (n - 3.0)) * sum4 - 3.0 * (n - 1.0).powi(2) / ((n - 2.0) * (n - 3.0))) +} + +fn skew(v: &[f64]) -> R { + let n = v.len() as f64; + if v.len() < 3 { + return Err(CellError::Div0); + } + let m = mean(v)?; + let s = var_s(v)?.sqrt(); + if s == 0.0 { + return Err(CellError::Div0); + } + let sum3: f64 = v.iter().map(|x| ((x - m) / s).powi(3)).sum(); + Ok(n / ((n - 1.0) * (n - 2.0)) * sum3) +} + +fn skew_p(v: &[f64]) -> R { + let m = mean(v)?; + let s = var_p(v)?.sqrt(); + if s == 0.0 { + return Err(CellError::Div0); + } + Ok(v.iter().map(|x| ((x - m) / s).powi(3)).sum::() / v.len() as f64) +} + +fn geomean(v: &[f64]) -> R { + if v.is_empty() || v.iter().any(|&x| x <= 0.0) { + return Err(CellError::Num); + } + Ok((v.iter().map(|x| x.ln()).sum::() / v.len() as f64).exp()) +} + +fn harmean(v: &[f64]) -> R { + if v.is_empty() || v.iter().any(|&x| x <= 0.0) { + return Err(CellError::Num); + } + Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::()) +} + +fn avedev(v: &[f64]) -> R { + if v.is_empty() { + return Err(CellError::Num); + } + let m = mean(v)?; + Ok(v.iter().map(|x| (x - m).abs()).sum::() / v.len() as f64) +} + +fn frequency(a: &[Arg], _: C) -> R { + let data = arg_nums(a, 0)?; + let bins = arg_nums(a, 1)?; + if bins.is_empty() { + return col_value(vec![data.len() as f64]); + } + let mut order: Vec = (0..bins.len()).collect(); + order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0))); + let mut counts = vec![0.0; bins.len() + 1]; + for x in data { + let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len()); + if let Some(c) = counts.get_mut(slot) { + *c += 1.0; + } + } + col_value(counts) +} + +fn prob(a: &[Arg], _: C) -> R { + let (xs, ps) = arg_pairs(a, 0, 1)?; + let lo = num(a, 2)?; + let hi = if has(a, 3) { num(a, 3)? } else { lo }; + if ps.iter().any(|&p| !(0.0..=1.0).contains(&p)) { + return Err(CellError::Num); + } + let total: f64 = ps.iter().sum(); + if (total - 1.0).abs() > 1e-7 { + return Err(CellError::Num); + } + num_val(xs.iter().zip(&ps).filter(|(x, _)| **x >= lo && **x <= hi).map(|(_, p)| p).sum()) +} + +fn standardize(a: &[Arg], _: C) -> R { + let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if sd <= 0.0 { + return Err(CellError::Num); + } + num_val((x - m) / sd) +} + +fn forecast(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let (ys, xs) = arg_pairs(a, 1, 2)?; + if ys.is_empty() { + return Err(CellError::Div0); + } + num_val(intercept(&ys, &xs)? + slope(&ys, &xs)? * x) +} + +fn steyx(a: &[Arg], _: C) -> R { + let (ys, xs) = arg_pairs(a, 0, 1)?; + let n = ys.len() as f64; + if ys.len() < 3 { + return Err(CellError::Div0); + } + let (sxy, sxx, syy, _, _) = moments(&xs, &ys)?; + if sxx == 0.0 { + return Err(CellError::Div0); + } + num_val(((syy - sxy * sxy / sxx) / (n - 2.0)).max(0.0).sqrt()) +} + +fn covariance(a: &[Arg], sample: bool) -> R { + let (xs, ys) = arg_pairs(a, 0, 1)?; + let n = xs.len(); + if n == 0 || (sample && n < 2) { + return Err(CellError::Div0); + } + let (sxy, ..) = moments(&xs, &ys)?; + num_val(sxy / if sample { (n - 1) as f64 } else { n as f64 }) +} + +// --------------------------------------------------------------------------------------------- +// Regression (LINEST, LOGEST, TREND, GROWTH) +// --------------------------------------------------------------------------------------------- + +struct Xy { + y: Vec, + /// n rows of k predictor values. + x: Vec>, + k: usize, + y_is_row: bool, + y_rows: usize, + y_cols: usize, +} + +fn strict_num(v: &Value) -> R { + match v { + Value::Number(n) => Ok(*n), + Value::Error(e) => Err(*e), + _ => Err(CellError::Value), + } +} + +fn parse_xy(y: &Value, x: Option<&Value>) -> R { + let ya = as_array(y); + let n = ya.data.len(); + let y_is_row = ya.rows == 1 && ya.cols > 1; + let yv: Vec = ya.data.iter().map(strict_num).collect::>()?; + let (xs, k) = match x { + None => ((1..=n).map(|i| vec![i as f64]).collect(), 1), + Some(xv) => { + let xa = as_array(xv); + if xa.rows == ya.rows && xa.cols == ya.cols { + (xa.data.iter().map(|v| strict_num(v).map(|f| vec![f])).collect::>>()?, 1) + } else if !y_is_row && xa.rows == n { + let mut rows = Vec::with_capacity(n); + for r in 0..n { + rows.push((0..xa.cols).map(|c| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); + } + (rows, xa.cols) + } else if y_is_row && xa.cols == n { + let mut rows = Vec::with_capacity(n); + for c in 0..n { + rows.push((0..xa.rows).map(|r| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); + } + (rows, xa.rows) + } else { + return Err(CellError::Ref); + } + } + }; + Ok(Xy { y: yv, x: xs, k, y_is_row, y_rows: ya.rows, y_cols: ya.cols }) +} + +struct Fit { + coef: Vec, + b: f64, + se: Vec, + se_b: Option, + r2: R, + sey: R, + f: R, + df: f64, + ssreg: f64, + ssresid: f64, +} + +/// Inverts a symmetric positive semi-definite matrix by Gauss–Jordan on the diagonal, dropping +/// (zeroing) collinear variables. Returns the inverse and the dropped flags. +fn sym_inverse(m: &[Vec]) -> (Vec>, Vec) { + let p = m.len(); + let mut a: Vec> = m.to_vec(); + let mut inv: Vec> = (0..p).map(|i| (0..p).map(|j| if i == j { 1.0 } else { 0.0 }).collect()).collect(); + let mut dropped = vec![false; p]; + let diag: Vec = (0..p).map(|i| m.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).abs()).collect(); + for j in 0..p { + let piv = a.get(j).and_then(|r| r.get(j)).copied().unwrap_or(0.0); + let scale = diag.get(j).copied().unwrap_or(0.0); + if piv.abs() <= 1e-12 * scale || piv.abs() < 1e-300 { + if let Some(d) = dropped.get_mut(j) { + *d = true; + } + for row in a.iter_mut() { + if let Some(v) = row.get_mut(j) { + *v = 0.0; + } + } + if let Some(r) = a.get_mut(j) { + r.iter_mut().for_each(|v| *v = 0.0); + } + if let Some(r) = inv.get_mut(j) { + r.iter_mut().for_each(|v| *v = 0.0); + } + continue; + } + let arow: Vec = a.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); + let irow: Vec = inv.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); + for i in 0..p { + if i == j { + continue; + } + let f = a.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); + if f == 0.0 { + continue; + } + if let Some(r) = a.get_mut(i) { + r.iter_mut().zip(&arow).for_each(|(v, w)| *v -= f * w); + } + if let Some(r) = inv.get_mut(i) { + r.iter_mut().zip(&irow).for_each(|(v, w)| *v -= f * w); + } + } + if let Some(r) = a.get_mut(j) { + *r = arow; + } + if let Some(r) = inv.get_mut(j) { + *r = irow; + } + } + (inv, dropped) +} + +fn regress(y: &[f64], x: &[Vec], k: usize, constant: bool) -> R { + let n = y.len(); + if n == 0 || x.len() != n { + return Err(CellError::Value); + } + let nf = n as f64; + let (xm, ym) = if constant { + let xm: Vec = (0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0)).sum::() / nf).collect(); + (xm, y.iter().sum::() / nf) + } else { + (vec![0.0; k], 0.0) + }; + // Normal equations on (centred) data. + let mut xtx = vec![vec![0.0; k]; k]; + let mut xty = vec![0.0; k]; + for (row, yv) in x.iter().zip(y) { + let d: Vec = (0..k).map(|j| row.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect(); + let yd = yv - ym; + for (i, di) in d.iter().enumerate() { + if let Some(t) = xty.get_mut(i) { + *t += di * yd; + } + if let Some(r) = xtx.get_mut(i) { + for (j, dj) in d.iter().enumerate() { + if let Some(v) = r.get_mut(j) { + *v += di * dj; + } + } + } + } + } + let (inv, dropped) = sym_inverse(&xtx); + let coef: Vec = (0..k).map(|i| inv.get(i).map(|r| r.iter().zip(&xty).map(|(a, b)| a * b).sum()).unwrap_or(0.0)).collect(); + let b = if constant { ym - coef.iter().zip(&xm).map(|(c, m)| c * m).sum::() } else { 0.0 }; + let mut ssresid = 0.0; + let mut sstot = 0.0; + for (row, yv) in x.iter().zip(y) { + let pred = b + coef.iter().zip(row).map(|(c, v)| c * v).sum::(); + ssresid += (yv - pred).powi(2); + sstot += if constant { (yv - ym).powi(2) } else { yv * yv }; + } + let used = dropped.iter().filter(|d| !**d).count() + usize::from(constant); + let df = nf - used as f64; + let ssreg = sstot - ssresid; + let r2 = if sstot == 0.0 { Err(CellError::Num) } else { Ok(ssreg / sstot) }; + let sey2 = if df > 0.0 { Ok(ssresid / df) } else { Err(CellError::Num) }; + let sey = sey2.map(f64::sqrt); + let v1 = if constant { nf - df - 1.0 } else { nf - df }; + let f = match sey2 { + Ok(s2) if s2 > 0.0 && v1 > 0.0 => Ok((ssreg / v1) / s2), + _ => Err(CellError::Num), + }; + let s2 = sey2.unwrap_or(f64::NAN); + let se: Vec = (0..k) + .map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (inv.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0) * s2).sqrt() }) + .collect(); + let se_b = if constant { + let mut q = 0.0; + for (i, mi) in xm.iter().enumerate() { + for (j, mj) in xm.iter().enumerate() { + q += mi * mj * inv.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); + } + } + Some((s2 * (1.0 / nf + q)).sqrt()) + } else { + None + }; + Ok(Fit { coef, b, se, se_b, r2, sey, f, df, ssreg, ssresid }) +} + +fn const_arg(a: &[Arg], i: usize) -> R { + if has(a, i) { boolean(a, i) } else { Ok(true) } +} + +fn opt_value(a: &[Arg], i: usize) -> Option<&Value> { + if has(a, i) { a.get(i).map(|x| &x.value) } else { None } +} + +fn linest_impl(a: &[Arg], log: bool) -> R { + let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; + if log { + if xy.y.iter().any(|&v| v <= 0.0) { + return Err(CellError::Num); + } + xy.y.iter_mut().for_each(|v| *v = v.ln()); + } + let constant = const_arg(a, 2)?; + let stats = if has(a, 3) { boolean(a, 3)? } else { false }; + let fit = regress(&xy.y, &xy.x, xy.k, constant)?; + let tr = |v: f64| if log { v.exp() } else { v }; + let cols = xy.k + 1; + let mut data: Vec = Vec::new(); + for c in fit.coef.iter().rev() { + data.push(Value::number(tr(*c))); + } + data.push(Value::number(tr(fit.b))); + if stats { + let rv = |r: R| r.map(Value::number).unwrap_or_else(Value::Error); + let na = Value::Error(CellError::NA); + for s in fit.se.iter().rev() { + data.push(Value::number(*s)); + } + data.push(fit.se_b.map(Value::number).unwrap_or(na.clone())); + let rows: [[Value; 2]; 3] = + [[rv(fit.r2), rv(fit.sey)], [rv(fit.f), Value::number(fit.df)], [Value::number(fit.ssreg), Value::number(fit.ssresid)]]; + for [p, q] in rows { + data.push(p); + if cols > 1 { + data.push(q); + } + for _ in 2..cols { + data.push(na.clone()); + } + } + return array_val(5, cols, data); + } + array_val(1, cols, data) +} + +fn trend_impl(a: &[Arg], log: bool) -> R { + let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; + if log { + if xy.y.iter().any(|&v| v <= 0.0) { + return Err(CellError::Num); + } + xy.y.iter_mut().for_each(|v| *v = v.ln()); + } + let constant = const_arg(a, 3)?; + let fit = regress(&xy.y, &xy.x, xy.k, constant)?; + let predict = |row: &[f64]| -> Value { + let v = fit.b + fit.coef.iter().zip(row).map(|(c, x)| c * x).sum::(); + Value::number(if log { v.exp() } else { v }) + }; + match opt_value(a, 2) { + None => { + let data: Vec = xy.x.iter().map(|r| predict(r)).collect(); + array_val(xy.y_rows, xy.y_cols, data) + } + Some(nv) => { + let na = as_array(nv); + if xy.k == 1 { + let data = na.data.iter().map(|v| strict_num(v).map(|x| predict(&[x]))).collect::>>()?; + array_val(na.rows, na.cols, data) + } else if !xy.y_is_row && na.cols == xy.k { + let mut data = Vec::with_capacity(na.rows); + for r in 0..na.rows { + let row = (0..na.cols).map(|c| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; + data.push(predict(&row)); + } + array_val(na.rows, 1, data) + } else if xy.y_is_row && na.rows == xy.k { + let mut data = Vec::with_capacity(na.cols); + for c in 0..na.cols { + let row = (0..na.rows).map(|r| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; + data.push(predict(&row)); + } + array_val(1, na.cols, data) + } else { + Err(CellError::Ref) + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// Distributions +// --------------------------------------------------------------------------------------------- + +fn ln_comb(n: f64, k: f64) -> f64 { + ln_gamma(n + 1.0) - ln_gamma(k + 1.0) - ln_gamma(n - k + 1.0) +} + +fn t_upper(t: f64, v: f64) -> f64 { + // P(T > t) for t ≥ 0. + 0.5 * reg_inc_beta(v / (v + t * t), v / 2.0, 0.5) +} + +fn t_cdf(t: f64, v: f64) -> f64 { + if t >= 0.0 { 1.0 - t_upper(t, v) } else { t_upper(-t, v) } +} + +fn t_pdf(t: f64, v: f64) -> f64 { + (ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp() +} + +/// t ≥ 0 with P(T > t) = q, q in (0, 0.5]. +fn t_inv_upper(q: f64, v: f64) -> f64 { + if q >= 0.5 { + return 0.0; + } + invert(|t| t_upper(t, v), q, 0.0, 1.0, 1e60) +} + +fn chi_inv(p: f64, v: f64) -> f64 { + if p <= 0.0 { + return 0.0; + } + invert(|x| reg_gamma_p(v / 2.0, x / 2.0), p, 0.0, v.max(1.0), 1e60) +} + +fn chi_inv_rt(q: f64, v: f64) -> f64 { + if q >= 1.0 { + return 0.0; + } + invert(|x| reg_gamma_q(v / 2.0, x / 2.0), q, 0.0, v.max(1.0), 1e60) +} + +fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 { + if x <= 0.0 { + return 0.0; + } + reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0) +} + +fn f_upper(x: f64, d1: f64, d2: f64) -> f64 { + if x <= 0.0 { + return 1.0; + } + reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0) +} + +fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 { + if x == 0.0 { + return if d1 < 2.0 { + f64::INFINITY + } else if d1 == 2.0 { + 1.0 + } else { + 0.0 + }; + } + ((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp() +} + +fn binom_pmf(k: f64, n: f64, p: f64) -> f64 { + if k < 0.0 || k > n { + return 0.0; + } + if p == 0.0 { + return if k == 0.0 { 1.0 } else { 0.0 }; + } + if p == 1.0 { + return if k == n { 1.0 } else { 0.0 }; + } + (ln_comb(n, k) + k * p.ln() + (n - k) * (1.0 - p).ln()).exp() +} + +fn binom_cdf(k: f64, n: f64, p: f64) -> f64 { + if k < 0.0 { + return 0.0; + } + if k >= n { + return 1.0; + } + if n <= 10_000.0 { + let mut s = 0.0; + let mut i = 0.0; + while i <= k { + s += binom_pmf(i, n, p); + i += 1.0; + } + return s.min(1.0); + } + if p == 0.0 { + return 1.0; + } + if p == 1.0 { + return 0.0; + } + reg_inc_beta(1.0 - p, n - k, k + 1.0) +} + +fn poisson_pmf(x: f64, m: f64) -> f64 { + if m == 0.0 { + return if x == 0.0 { 1.0 } else { 0.0 }; + } + (-m + x * m.ln() - ln_gamma(x + 1.0)).exp() +} + +fn poisson_cdf(x: f64, m: f64) -> f64 { + if m == 0.0 { + return 1.0; + } + if x <= 1000.0 { + let mut s = 0.0; + let mut i = 0.0; + while i <= x { + s += poisson_pmf(i, m); + i += 1.0; + } + return s.min(1.0); + } + reg_gamma_q(x + 1.0, m) +} + +fn norm_dist(a: &[Arg], _: C) -> R { + let (x, m, sd, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + if sd <= 0.0 { + return Err(CellError::Num); + } + let z = (x - m) / sd; + num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / sd }) +} + +fn norm_inv_fn(a: &[Arg], _: C) -> R { + let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if !(p > 0.0 && p < 1.0) || sd <= 0.0 { + return Err(CellError::Num); + } + num_val(m + sd * norm_inv(p)) +} + +fn norm_s_dist(a: &[Arg], _: C) -> R { + let z = num(a, 0)?; + let cum = if a.len() > 1 { boolean(a, 1)? } else { true }; + num_val(if cum { norm_cdf(z) } else { norm_pdf(z) }) +} + +fn norm_s_inv(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + if !(p > 0.0 && p < 1.0) { + return Err(CellError::Num); + } + num_val(norm_inv(p)) +} + +fn df_arg(a: &[Arg], i: usize) -> R { + let v = num(a, i)?.trunc(); + if v < 1.0 { + return Err(CellError::Num); + } + Ok(v) +} + +fn t_dist(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + let cum = boolean(a, 2)?; + num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) }) +} + +fn t_dist_2t(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + if x < 0.0 { + return Err(CellError::Num); + } + num_val(2.0 * t_upper(x, v)) +} + +fn t_dist_rt(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + num_val(1.0 - t_cdf(x, v)) +} + +fn tdist_legacy(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + let tails = num(a, 2)?.trunc(); + if x < 0.0 || !(tails == 1.0 || tails == 2.0) { + return Err(CellError::Num); + } + num_val(tails * t_upper(x, v)) +} + +fn t_inv(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(p > 0.0 && p < 1.0) { + return Err(CellError::Num); + } + num_val(if p < 0.5 { -t_inv_upper(p, v) } else { t_inv_upper(1.0 - p, v) }) +} + +fn t_inv_2t(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(p > 0.0 && p <= 1.0) { + return Err(CellError::Num); + } + num_val(t_inv_upper(p / 2.0, v)) +} + +fn t_test(a: &[Arg], _: C) -> R { + let tails = num(a, 2)?.trunc(); + let ty = num(a, 3)?.trunc(); + if !(tails == 1.0 || tails == 2.0) || !(1.0..=3.0).contains(&ty) { + return Err(CellError::Num); + } + let (t, v) = if ty == 1.0 { + let (x, y) = arg_pairs(a, 0, 1)?; + let d: Vec = x.iter().zip(&y).map(|(p, q)| p - q).collect(); + let n = d.len() as f64; + let sd = var_s(&d)?.sqrt(); + if sd == 0.0 { + return Err(CellError::Div0); + } + (mean(&d)? / (sd / n.sqrt()), n - 1.0) + } else { + let x = arg_nums(a, 0)?; + let y = arg_nums(a, 1)?; + let (n1, n2) = (x.len() as f64, y.len() as f64); + let (v1, v2) = (var_s(&x)?, var_s(&y)?); + let diff = mean(&x)? - mean(&y)?; + if ty == 2.0 { + let df = n1 + n2 - 2.0; + let sp = ((n1 - 1.0) * v1 + (n2 - 1.0) * v2) / df; + let se = (sp * (1.0 / n1 + 1.0 / n2)).sqrt(); + if se == 0.0 { + return Err(CellError::Div0); + } + (diff / se, df) + } else { + let (a1, a2) = (v1 / n1, v2 / n2); + let se = (a1 + a2).sqrt(); + if se == 0.0 { + return Err(CellError::Div0); + } + let df = (a1 + a2).powi(2) / (a1 * a1 / (n1 - 1.0) + a2 * a2 / (n2 - 1.0)); + (diff / se, df) + } + }; + num_val(tails * t_upper(t.abs(), v)) +} + +fn chisq_dist(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + let cum = boolean(a, 2)?; + if x < 0.0 || v > 1e10 { + return Err(CellError::Num); + } + if cum { + return num_val(reg_gamma_p(v / 2.0, x / 2.0)); + } + if x == 0.0 { + return if v == 2.0 { + num_val(0.5) + } else if v < 2.0 { + Err(CellError::Num) + } else { + num_val(0.0) + }; + } + num_val(((v / 2.0 - 1.0) * x.ln() - x / 2.0 - (v / 2.0) * 2f64.ln() - ln_gamma(v / 2.0)).exp()) +} + +fn chisq_dist_rt(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + if x < 0.0 || v > 1e10 { + return Err(CellError::Num); + } + num_val(reg_gamma_q(v / 2.0, x / 2.0)) +} + +fn chisq_inv(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(0.0..1.0).contains(&p) || v > 1e10 { + return Err(CellError::Num); + } + num_val(chi_inv(p, v)) +} + +fn chisq_inv_rt(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(p > 0.0 && p <= 1.0) || v > 1e10 { + return Err(CellError::Num); + } + num_val(chi_inv_rt(p, v)) +} + +fn chisq_test(a: &[Arg], _: C) -> R { + let act = as_array(&arg(a, 0)?.value).into_owned(); + let exp = as_array(&arg(a, 1)?.value).into_owned(); + if act.rows != exp.rows || act.cols != exp.cols { + return Err(CellError::NA); + } + let mut x2 = 0.0; + for (o, e) in act.data.iter().zip(&exp.data) { + if let Value::Error(err) = o { + return Err(*err); + } + if let Value::Error(err) = e { + return Err(*err); + } + if let (Value::Number(o), Value::Number(e)) = (o, e) { + if *e == 0.0 { + return Err(CellError::Div0); + } + x2 += (o - e).powi(2) / e; + } + } + let df = if act.rows > 1 && act.cols > 1 { ((act.rows - 1) * (act.cols - 1)) as f64 } else { (act.data.len().saturating_sub(1)) as f64 }; + if df < 1.0 { + return Err(CellError::NA); + } + num_val(reg_gamma_q(df / 2.0, x2 / 2.0)) +} + +fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> { + let x = num(a, 0)?; + let d1 = num(a, 1)?.trunc(); + let d2 = num(a, 2)?.trunc(); + if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 { + return Err(CellError::Num); + } + Ok((x, d1, d2)) +} + +fn f_dist(a: &[Arg], _: C) -> R { + let (x, d1, d2) = f_args(a)?; + let cum = boolean(a, 3)?; + if x < 0.0 { + return Err(CellError::Num); + } + num_val(if cum { f_cdf(x, d1, d2) } else { f_pdf(x, d1, d2) }) +} + +fn f_dist_rt(a: &[Arg], _: C) -> R { + let (x, d1, d2) = f_args(a)?; + if x < 0.0 { + return Err(CellError::Num); + } + num_val(f_upper(x, d1, d2)) +} + +fn f_inv(a: &[Arg], _: C) -> R { + let (p, d1, d2) = f_args(a)?; + if !(0.0..1.0).contains(&p) { + return Err(CellError::Num); + } + if p == 0.0 { + return num_val(0.0); + } + num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60)) +} + +fn f_inv_rt(a: &[Arg], _: C) -> R { + let (p, d1, d2) = f_args(a)?; + if !(p > 0.0 && p <= 1.0) { + return Err(CellError::Num); + } + if p == 1.0 { + return num_val(0.0); + } + num_val(invert(|x| f_upper(x, d1, d2), p, 0.0, 1.0, 1e60)) +} + +fn f_test(a: &[Arg], _: C) -> R { + let x = arg_nums(a, 0)?; + let y = arg_nums(a, 1)?; + let (v1, v2) = (var_s(&x)?, var_s(&y)?); + if v1 == 0.0 || v2 == 0.0 { + return Err(CellError::Div0); + } + let f = v1 / v2; + let (d1, d2) = ((x.len() - 1) as f64, (y.len() - 1) as f64); + let c = f_cdf(f, d1, d2); + num_val((2.0 * c.min(1.0 - c)).min(1.0)) +} + +fn binom_dist(a: &[Arg], _: C) -> R { + let k = num(a, 0)?.trunc(); + let n = num(a, 1)?.trunc(); + let p = num(a, 2)?; + let cum = boolean(a, 3)?; + if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) { + return Err(CellError::Num); + } + num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) }) +} + +fn binom_dist_range(a: &[Arg], _: C) -> R { + let n = num(a, 0)?.trunc(); + let p = num(a, 1)?; + let s1 = num(a, 2)?.trunc(); + let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; + if n < 0.0 || n > 1e15 || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { + return Err(CellError::Num); + } + if s2 - s1 > 10_000.0 { + return num_val(binom_cdf(s2, n, p) - binom_cdf(s1 - 1.0, n, p)); + } + let mut s = 0.0; + let mut k = s1; + while k <= s2 { + s += binom_pmf(k, n, p); + k += 1.0; + } + num_val(s) +} + +fn binom_inv(a: &[Arg], _: C) -> R { + let n = num(a, 0)?.trunc(); + let p = num(a, 1)?; + let alpha = num(a, 2)?; + if n < 0.0 || !(0.0..=1.0).contains(&p) || !(0.0..=1.0).contains(&alpha) { + return Err(CellError::Num); + } + // Smallest k with cdf(k) ≥ alpha, by bisection on the integer k. + let (mut lo, mut hi) = (0.0f64, n); + if binom_cdf(0.0, n, p) >= alpha { + return num_val(0.0); + } + while hi - lo > 1.0 { + let mid = ((lo + hi) / 2.0).floor(); + if binom_cdf(mid, n, p) >= alpha { + hi = mid; + } else { + lo = mid; + } + } + num_val(hi) +} + +fn poisson_dist(a: &[Arg], _: C) -> R { + let x = num(a, 0)?.trunc(); + let m = num(a, 1)?; + let cum = boolean(a, 2)?; + if x < 0.0 || m < 0.0 { + return Err(CellError::Num); + } + num_val(if cum { poisson_cdf(x, m) } else { poisson_pmf(x, m) }) +} + +fn expon_dist(a: &[Arg], _: C) -> R { + let (x, l, cum) = (num(a, 0)?, num(a, 1)?, boolean(a, 2)?); + if x < 0.0 || l <= 0.0 { + return Err(CellError::Num); + } + num_val(if cum { -(-l * x).exp_m1() } else { l * (-l * x).exp() }) +} + +fn gamma_fn(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let g = gamma(x); + if !g.is_finite() { + return Err(CellError::Num); + } + num_val(g) +} + +fn gamma_dist(a: &[Arg], _: C) -> R { + let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + if x < 0.0 || al <= 0.0 || be <= 0.0 { + return Err(CellError::Num); + } + if cum { + return num_val(reg_gamma_p(al, x / be)); + } + if x == 0.0 { + return if al < 1.0 { + Err(CellError::Num) + } else if al == 1.0 { + num_val(1.0 / be) + } else { + num_val(0.0) + }; + } + num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp()) +} + +fn gamma_inv(a: &[Arg], _: C) -> R { + let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if !(0.0..1.0).contains(&p) || al <= 0.0 || be <= 0.0 { + return Err(CellError::Num); + } + if p == 0.0 { + return num_val(0.0); + } + num_val(invert(|x| reg_gamma_p(al, x / be), p, 0.0, (al * be).max(1e-3), 1e60)) +} + +fn gammaln(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + if x <= 0.0 { + return Err(CellError::Num); + } + num_val(ln_gamma(x)) +} + +fn beta_bounds(a: &[Arg], i: usize) -> R<(f64, f64)> { + let lo = if has(a, i) { num(a, i)? } else { 0.0 }; + let hi = if has(a, i + 1) { num(a, i + 1)? } else { 1.0 }; + if lo >= hi { + return Err(CellError::Num); + } + Ok((lo, hi)) +} + +fn beta_dist(a: &[Arg], _: C) -> R { + let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + let (lo, hi) = beta_bounds(a, 4)?; + if al <= 0.0 || be <= 0.0 || x < lo || x > hi { + return Err(CellError::Num); + } + let z = (x - lo) / (hi - lo); + if cum { + return num_val(reg_inc_beta(z, al, be)); + } + if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) { + return Err(CellError::Num); + } + let lp = if z == 0.0 || z == 1.0 { + let base = if z == 0.0 { al } else { be }; + if base == 1.0 { -ln_beta(al, be) } else { f64::NEG_INFINITY } + } else { + (al - 1.0) * z.ln() + (be - 1.0) * (1.0 - z).ln() - ln_beta(al, be) + }; + num_val(lp.exp() / (hi - lo)) +} + +fn betadist_legacy(a: &[Arg], _: C) -> R { + let (x, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (lo, hi) = beta_bounds(a, 3)?; + if al <= 0.0 || be <= 0.0 || x < lo || x > hi { + return Err(CellError::Num); + } + num_val(reg_inc_beta((x - lo) / (hi - lo), al, be)) +} + +fn beta_inv(a: &[Arg], _: C) -> R { + let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (lo, hi) = beta_bounds(a, 3)?; + if al <= 0.0 || be <= 0.0 || !(0.0..=1.0).contains(&p) { + return Err(CellError::Num); + } + let z = if p == 0.0 { + 0.0 + } else if p == 1.0 { + 1.0 + } else { + invert(|x| reg_inc_beta(x, al, be), p, 0.0, 1.0, 1.0) + }; + num_val(lo + z * (hi - lo)) +} + +fn lognorm_dist(a: &[Arg], _: C) -> R { + let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let cum = if a.len() > 3 { boolean(a, 3)? } else { true }; + if x <= 0.0 || sd <= 0.0 { + return Err(CellError::Num); + } + let z = (x.ln() - m) / sd; + num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / (x * sd) }) +} + +fn lognorm_inv(a: &[Arg], _: C) -> R { + let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if !(p > 0.0 && p < 1.0) || sd <= 0.0 { + return Err(CellError::Num); + } + num_val((m + sd * norm_inv(p)).exp()) +} + +fn weibull_dist(a: &[Arg], _: C) -> R { + let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + if x < 0.0 || al <= 0.0 || be <= 0.0 { + return Err(CellError::Num); + } + let t = (x / be).powf(al); + num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() }) +} + +fn hypgeom_dist(a: &[Arg], _: C) -> R { + let k = num(a, 0)?.trunc(); + let n = num(a, 1)?.trunc(); + let kk = num(a, 2)?.trunc(); + let nn = num(a, 3)?.trunc(); + let cum = if a.len() > 4 { boolean(a, 4)? } else { false }; + if k < 0.0 || n <= 0.0 || kk <= 0.0 || nn <= 0.0 || n > nn || kk > nn || k > n.min(kk) || k < (n - nn + kk).max(0.0) { + return Err(CellError::Num); + } + let pmf = |i: f64| (ln_comb(kk, i) + ln_comb(nn - kk, n - i) - ln_comb(nn, n)).exp(); + if !cum { + return num_val(pmf(k)); + } + let mut s = 0.0; + let mut i = (n - nn + kk).max(0.0); + if k - i > 1e6 || i + 1.0 == i { + return Err(CellError::Num); + } + while i <= k { + s += pmf(i); + i += 1.0; + } + num_val(s.min(1.0)) +} + +fn negbinom_dist(a: &[Arg], _: C) -> R { + let f = num(a, 0)?.trunc(); + let s = num(a, 1)?.trunc(); + let p = num(a, 2)?; + let cum = if a.len() > 3 { boolean(a, 3)? } else { false }; + if f < 0.0 || s < 1.0 || !(0.0..=1.0).contains(&p) { + return Err(CellError::Num); + } + if cum { + if p == 0.0 { + return num_val(0.0); + } + return num_val(reg_inc_beta(p, s, f + 1.0)); + } + if p == 0.0 || p == 1.0 { + return num_val(if p == 1.0 && f == 0.0 { 1.0 } else { 0.0 }); + } + num_val((ln_comb(f + s - 1.0, s - 1.0) + s * p.ln() + f * (1.0 - p).ln()).exp()) +} + +fn confidence_norm(a: &[Arg], _: C) -> R { + let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); + if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + num_val(norm_inv(1.0 - al / 2.0) * sd / n.sqrt()) +} + +fn confidence_t(a: &[Arg], _: C) -> R { + let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); + if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + if n == 1.0 { + return Err(CellError::Div0); + } + num_val(t_inv_upper(al / 2.0, n - 1.0) * sd / n.sqrt()) +} + +fn z_test(a: &[Arg], _: C) -> R { + let v = arg_nums(a, 0)?; + let x = num(a, 1)?; + if v.is_empty() { + return Err(CellError::NA); + } + let sd = if has(a, 2) { num(a, 2)? } else { var_s(&v)?.sqrt() }; + if sd <= 0.0 { + return Err(if has(a, 2) { CellError::Num } else { CellError::Div0 }); + } + let z = (mean(&v)? - x) / (sd / (v.len() as f64).sqrt()); + num_val(1.0 - norm_cdf(z)) +} + +fn fisher(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + if x <= -1.0 || x >= 1.0 { + return Err(CellError::Num); + } + num_val(0.5 * ((1.0 + x) / (1.0 - x)).ln()) +} + +fn gauss(a: &[Arg], _: C) -> R { + let z = num(a, 0)?; + // Φ(z) − 0.5 = erf(z/√2)/2, accurate near zero. + num_val(0.5 * erf(z / std::f64::consts::SQRT_2)) +} + +// --------------------------------------------------------------------------------------------- +// SUBTOTAL / AGGREGATE support +// --------------------------------------------------------------------------------------------- + +/// Evaluates SUBTOTAL/AGGREGATE function `function_num` over already-filtered cell values. +/// +/// Codes 1–11 (and 101–111) are AVERAGE, COUNT, COUNTA, MAX, MIN, PRODUCT, STDEV.S, STDEV.P, +/// SUM, VAR.S, VAR.P; 12 = MEDIAN, 13 = MODE.SNGL. Codes 14–19 need `k`: use +/// [`aggregate_values_k`] (they give `#VALUE!` here). Values that are not numbers are ignored +/// (COUNTA counts non-empty values); the first error value present is returned. +pub fn aggregate_values(function_num: u32, values: &[Value]) -> Value { + crate::util::finish(aggregate_impl(function_num, values, None)) +} + +/// AGGREGATE codes 14 = LARGE, 15 = SMALL, 16 = PERCENTILE.INC, 17 = QUARTILE.INC, +/// 18 = PERCENTILE.EXC, 19 = QUARTILE.EXC with their `k` argument; codes 1–13 delegate to +/// [`aggregate_values`]. +pub fn aggregate_values_k(function_num: u32, values: &[Value], k: &Value) -> Value { + crate::util::finish(aggregate_impl(function_num, values, Some(k))) +} + +fn aggregate_impl(function_num: u32, values: &[Value], k: Option<&Value>) -> R { + let code = if (101..=111).contains(&function_num) { function_num - 100 } else { function_num }; + let mut xs = Vec::new(); + let mut nonempty = 0usize; + let mut push = |v: &Value| -> R<()> { + match v { + Value::Number(n) => xs.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + if !v.is_empty() { + nonempty += 1; + } + Ok(()) + }; + for v in values { + match v { + Value::Array(arr) => arr.iter().try_for_each(&mut push)?, + other => push(other)?, + } + } + let k = match (code, k) { + (14..=19, Some(k)) => k.scalar().to_number()?, + (14..=19, None) => return Err(CellError::Value), + _ => 0.0, + }; + let r = match code { + 1 => mean(&xs)?, + 2 => xs.len() as f64, + 3 => nonempty as f64, + 4 => max_of(&xs), + 5 => min_of(&xs), + 6 => product(&xs), + 7 => var_s(&xs)?.sqrt(), + 8 => var_p(&xs)?.sqrt(), + 9 => xs.iter().sum(), + 10 => var_s(&xs)?, + 11 => var_p(&xs)?, + 12 => median(xs)?, + 13 => mode_sngl(&xs)?, + 14 => kth(xs, k, true)?, + 15 => kth(xs, k, false)?, + 16 => percentile_inc(xs, k)?, + 17 => quartile_inc(xs, k)?, + 18 => percentile_exc(xs, k)?, + 19 => quartile_exc(xs, k)?, + _ => return Err(CellError::Value), + }; + num_val(r) +} + +// --------------------------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------------------------- + +const S_ARR_K: &[bool] = &[false, true]; +const S_RANK: &[bool] = &[true, false, true]; +const S_PRANK: &[bool] = &[false, true, true]; +const S_FORECAST: &[bool] = &[true, false, false]; +const S_PROB: &[bool] = &[false, false, true, true]; +const S_TTEST: &[bool] = &[false, false, true, true]; + +pub(crate) fn specs() -> Vec { + vec![ + f!("AVERAGE", 1, VAR, Statistical, A, "AVERAGE(number1, [number2], ...)", "Arithmetic mean of the numbers.", |a: &[Arg], _c: C| num_val( + mean(&numbers(a)?)? + )), + f!( + "AVERAGEA", + 1, + VAR, + Statistical, + A, + "AVERAGEA(value1, [value2], ...)", + "Mean counting text as 0 and logical values as 1 or 0.", + |a: &[Arg], _c: C| num_val(mean(&numbers_a(a)?)?) + ), + f!("MEDIAN", 1, VAR, Statistical, A, "MEDIAN(number1, [number2], ...)", "Middle value of the numbers.", |a: &[Arg], _c: C| num_val(median( + numbers(a)? + )?)), + f!("MODE", 1, VAR, Compatibility, A, "MODE(number1, [number2], ...)", "Most frequently occurring number.", |a: &[Arg], _c: C| num_val( + mode_sngl(&numbers(a)?)? + )), + f!("MODE.SNGL", 1, VAR, Statistical, A, "MODE.SNGL(number1, [number2], ...)", "Most frequently occurring number.", |a: &[Arg], _c: C| { + num_val(mode_sngl(&numbers(a)?)?) + }), + f!("MODE.MULT", 1, VAR, Statistical, A, "MODE.MULT(number1, [number2], ...)", "Vertical array of all the most frequent numbers.", mode_mult), + f!("STDEV", 1, VAR, Compatibility, A, "STDEV(number1, [number2], ...)", "Sample standard deviation.", |a: &[Arg], _c: C| num_val( + var_s(&numbers(a)?)?.sqrt() + )), + f!("STDEV.S", 1, VAR, Statistical, A, "STDEV.S(number1, [number2], ...)", "Sample standard deviation.", |a: &[Arg], _c: C| num_val( + var_s(&numbers(a)?)?.sqrt() + )), + f!("STDEV.P", 1, VAR, Statistical, A, "STDEV.P(number1, [number2], ...)", "Population standard deviation.", |a: &[Arg], _c: C| num_val( + var_p(&numbers(a)?)?.sqrt() + )), + f!("STDEVP", 1, VAR, Compatibility, A, "STDEVP(number1, [number2], ...)", "Population standard deviation.", |a: &[Arg], _c: C| num_val( + var_p(&numbers(a)?)?.sqrt() + )), + f!( + "STDEVA", + 1, + VAR, + Statistical, + A, + "STDEVA(value1, [value2], ...)", + "Sample standard deviation counting text as 0 and logicals as 1/0.", + |a: &[Arg], _c: C| { num_val(var_s(&numbers_a(a)?)?.sqrt()) } + ), + f!( + "STDEVPA", + 1, + VAR, + Statistical, + A, + "STDEVPA(value1, [value2], ...)", + "Population standard deviation counting text as 0 and logicals as 1/0.", + |a: &[Arg], _c: C| { num_val(var_p(&numbers_a(a)?)?.sqrt()) } + ), + f!("VAR", 1, VAR, Compatibility, A, "VAR(number1, [number2], ...)", "Sample variance.", |a: &[Arg], _c: C| num_val(var_s(&numbers(a)?)?)), + f!("VAR.S", 1, VAR, Statistical, A, "VAR.S(number1, [number2], ...)", "Sample variance.", |a: &[Arg], _c: C| num_val(var_s(&numbers(a)?)?)), + f!("VAR.P", 1, VAR, Statistical, A, "VAR.P(number1, [number2], ...)", "Population variance.", |a: &[Arg], _c: C| num_val(var_p(&numbers( + a + )?)?)), + f!("VARP", 1, VAR, Compatibility, A, "VARP(number1, [number2], ...)", "Population variance.", |a: &[Arg], _c: C| num_val(var_p(&numbers( + a + )?)?)), + f!( + "VARA", + 1, + VAR, + Statistical, + A, + "VARA(value1, [value2], ...)", + "Sample variance counting text as 0 and logicals as 1/0.", + |a: &[Arg], _c: C| num_val(var_s(&numbers_a(a)?)?) + ), + f!( + "VARPA", + 1, + VAR, + Statistical, + A, + "VARPA(value1, [value2], ...)", + "Population variance counting text as 0 and logicals as 1/0.", + |a: &[Arg], _c: C| num_val(var_p(&numbers_a(a)?)?) + ), + f!("MIN", 1, VAR, Statistical, A, "MIN(number1, [number2], ...)", "Smallest number (0 when there are none).", |a: &[Arg], _c: C| num_val( + min_of(&numbers(a)?) + )), + f!( + "MINA", + 1, + VAR, + Statistical, + A, + "MINA(value1, [value2], ...)", + "Smallest value counting text as 0 and logicals as 1/0.", + |a: &[Arg], _c: C| num_val(min_of(&numbers_a(a)?)) + ), + f!("MAX", 1, VAR, Statistical, A, "MAX(number1, [number2], ...)", "Largest number (0 when there are none).", |a: &[Arg], _c: C| num_val( + max_of(&numbers(a)?) + )), + f!( + "MAXA", + 1, + VAR, + Statistical, + A, + "MAXA(value1, [value2], ...)", + "Largest value counting text as 0 and logicals as 1/0.", + |a: &[Arg], _c: C| num_val(max_of(&numbers_a(a)?)) + ), + f!("LARGE", 2, 2, Statistical, S_ARR_K, "LARGE(array, k)", "The k-th largest number in a data set.", large), + f!("SMALL", 2, 2, Statistical, S_ARR_K, "SMALL(array, k)", "The k-th smallest number in a data set.", small), + f!("RANK", 2, 3, Compatibility, S_RANK, "RANK(number, ref, [order])", "Rank of a number within a list.", |a: &[Arg], _c: C| rank_impl( + a, false + )), + f!( + "RANK.EQ", + 2, + 3, + Statistical, + S_RANK, + "RANK.EQ(number, ref, [order])", + "Rank of a number within a list; ties share the top rank.", + |a: &[Arg], _c: C| rank_impl(a, false) + ), + f!( + "RANK.AVG", + 2, + 3, + Statistical, + S_RANK, + "RANK.AVG(number, ref, [order])", + "Rank of a number within a list; ties get the average rank.", + |a: &[Arg], _c: C| { rank_impl(a, true) } + ), + f!("PERCENTILE", 2, 2, Compatibility, S_ARR_K, "PERCENTILE(array, k)", "k-th percentile, k from 0 to 1 inclusive.", |a: &[Arg], _c: C| { + num_val(percentile_inc(arg_nums(a, 0)?, num(a, 1)?)?) + }), + f!( + "PERCENTILE.INC", + 2, + 2, + Statistical, + S_ARR_K, + "PERCENTILE.INC(array, k)", + "k-th percentile, k from 0 to 1 inclusive.", + |a: &[Arg], _c: C| num_val(percentile_inc(arg_nums(a, 0)?, num(a, 1)?)?) + ), + f!( + "PERCENTILE.EXC", + 2, + 2, + Statistical, + S_ARR_K, + "PERCENTILE.EXC(array, k)", + "k-th percentile, k strictly between 0 and 1.", + |a: &[Arg], _c: C| num_val(percentile_exc(arg_nums(a, 0)?, num(a, 1)?)?) + ), + f!("QUARTILE", 2, 2, Compatibility, S_ARR_K, "QUARTILE(array, quart)", "Quartile (0–4) of a data set.", |a: &[Arg], _c: C| num_val( + quartile_inc(arg_nums(a, 0)?, num(a, 1)?)? + )), + f!( + "QUARTILE.INC", + 2, + 2, + Statistical, + S_ARR_K, + "QUARTILE.INC(array, quart)", + "Quartile (0–4) of a data set, inclusive method.", + |a: &[Arg], _c: C| num_val(quartile_inc(arg_nums(a, 0)?, num(a, 1)?)?) + ), + f!( + "QUARTILE.EXC", + 2, + 2, + Statistical, + S_ARR_K, + "QUARTILE.EXC(array, quart)", + "Quartile (1–3) of a data set, exclusive method.", + |a: &[Arg], _c: C| num_val(quartile_exc(arg_nums(a, 0)?, num(a, 1)?)?) + ), + f!( + "PERCENTRANK", + 2, + 3, + Compatibility, + S_PRANK, + "PERCENTRANK(array, x, [significance])", + "Relative standing of a value as a fraction of the data set.", + |a: &[Arg], _c: C| { percentrank_impl(a, false) } + ), + f!( + "PERCENTRANK.INC", + 2, + 3, + Statistical, + S_PRANK, + "PERCENTRANK.INC(array, x, [significance])", + "Relative standing of a value from 0 to 1 inclusive.", + |a: &[Arg], _c: C| percentrank_impl(a, false) + ), + f!( + "PERCENTRANK.EXC", + 2, + 3, + Statistical, + S_PRANK, + "PERCENTRANK.EXC(array, x, [significance])", + "Relative standing of a value strictly between 0 and 1.", + |a: &[Arg], _c: C| percentrank_impl(a, true) + ), + f!("COUNT", 1, VAR, Statistical, A, "COUNT(value1, [value2], ...)", "Counts the numbers.", |a: &[Arg], _c: C| num_val(count_impl(a))), + f!("COUNTA", 1, VAR, Statistical, A, "COUNTA(value1, [value2], ...)", "Counts the non-empty values.", |a: &[Arg], _c: C| num_val( + counta_impl(a) + )), + f!("COUNTBLANK", 1, 1, Statistical, A, "COUNTBLANK(range)", "Counts empty cells and empty text in a range.", countblank), + f!("CORREL", 2, 2, Statistical, A, "CORREL(array1, array2)", "Correlation coefficient of two data sets.", |a: &[Arg], _c: C| { + let (x, y) = arg_pairs(a, 0, 1)?; + num_val(correl(&x, &y)?) + }), + f!("PEARSON", 2, 2, Statistical, A, "PEARSON(array1, array2)", "Pearson product-moment correlation coefficient.", |a: &[Arg], _c: C| { + let (x, y) = arg_pairs(a, 0, 1)?; + num_val(correl(&x, &y)?) + }), + f!("RSQ", 2, 2, Statistical, A, "RSQ(known_ys, known_xs)", "Square of the correlation coefficient.", |a: &[Arg], _c: C| { + let (y, x) = arg_pairs(a, 0, 1)?; + num_val(correl(&x, &y)?.powi(2)) + }), + f!("SLOPE", 2, 2, Statistical, A, "SLOPE(known_ys, known_xs)", "Slope of the least-squares regression line.", |a: &[Arg], _c: C| { + let (y, x) = arg_pairs(a, 0, 1)?; + if y.is_empty() { + return Err(CellError::Div0); + } + num_val(slope(&y, &x)?) + }), + f!( + "INTERCEPT", + 2, + 2, + Statistical, + A, + "INTERCEPT(known_ys, known_xs)", + "Y-intercept of the least-squares regression line.", + |a: &[Arg], _c: C| { + let (y, x) = arg_pairs(a, 0, 1)?; + if y.is_empty() { + return Err(CellError::Div0); + } + num_val(intercept(&y, &x)?) + } + ), + f!("STEYX", 2, 2, Statistical, A, "STEYX(known_ys, known_xs)", "Standard error of predicted y values in a regression.", steyx), + f!("FORECAST", 3, 3, Compatibility, S_FORECAST, "FORECAST(x, known_ys, known_xs)", "Predicts a value along a linear trend.", forecast), + f!( + "FORECAST.LINEAR", + 3, + 3, + Statistical, + S_FORECAST, + "FORECAST.LINEAR(x, known_ys, known_xs)", + "Predicts a value along a linear trend.", + forecast + ), + f!( + "TREND", + 1, + 4, + Statistical, + A, + "TREND(known_ys, [known_xs], [new_xs], [const])", + "Values along a least-squares linear fit.", + |a: &[Arg], _c: C| trend_impl(a, false) + ), + f!( + "GROWTH", + 1, + 4, + Statistical, + A, + "GROWTH(known_ys, [known_xs], [new_xs], [const])", + "Values along a fitted exponential growth curve.", + |a: &[Arg], _c: C| { trend_impl(a, true) } + ), + f!( + "LINEST", + 1, + 4, + Statistical, + A, + "LINEST(known_ys, [known_xs], [const], [stats])", + "Least-squares line coefficients and optional regression statistics.", + |a: &[Arg], _c: C| { linest_impl(a, false) } + ), + f!( + "LOGEST", + 1, + 4, + Statistical, + A, + "LOGEST(known_ys, [known_xs], [const], [stats])", + "Exponential curve coefficients and optional regression statistics.", + |a: &[Arg], _c: C| { linest_impl(a, true) } + ), + f!("COVARIANCE.P", 2, 2, Statistical, A, "COVARIANCE.P(array1, array2)", "Population covariance of paired values.", |a: &[Arg], _c: C| { + covariance(a, false) + }), + f!("COVARIANCE.S", 2, 2, Statistical, A, "COVARIANCE.S(array1, array2)", "Sample covariance of paired values.", |a: &[Arg], _c: C| { + covariance(a, true) + }), + f!("COVAR", 2, 2, Compatibility, A, "COVAR(array1, array2)", "Population covariance of paired values.", |a: &[Arg], _c: C| covariance( + a, false + )), + f!("DEVSQ", 1, VAR, Statistical, A, "DEVSQ(number1, [number2], ...)", "Sum of squared deviations from the mean.", |a: &[Arg], _c: C| { + let v = numbers(a)?; + if v.is_empty() { + return Err(CellError::Num); + } + num_val(devsq(&v)) + }), + f!("AVEDEV", 1, VAR, Statistical, A, "AVEDEV(number1, [number2], ...)", "Average absolute deviation from the mean.", |a: &[Arg], _c: C| { + num_val(avedev(&numbers(a)?)?) + }), + f!("GEOMEAN", 1, VAR, Statistical, A, "GEOMEAN(number1, [number2], ...)", "Geometric mean of positive numbers.", |a: &[Arg], _c: C| num_val( + geomean(&numbers(a)?)? + )), + f!("HARMEAN", 1, VAR, Statistical, A, "HARMEAN(number1, [number2], ...)", "Harmonic mean of positive numbers.", |a: &[Arg], _c: C| num_val( + harmean(&numbers(a)?)? + )), + f!("TRIMMEAN", 2, 2, Statistical, S_ARR_K, "TRIMMEAN(array, percent)", "Mean after trimming a fraction of points from both ends.", trimmean), + f!("KURT", 1, VAR, Statistical, A, "KURT(number1, [number2], ...)", "Excess kurtosis of a sample.", |a: &[Arg], _c: C| num_val(kurt( + &numbers(a)? + )?)), + f!("SKEW", 1, VAR, Statistical, A, "SKEW(number1, [number2], ...)", "Sample skewness.", |a: &[Arg], _c: C| num_val(skew(&numbers(a)?)?)), + f!("SKEW.P", 1, VAR, Statistical, A, "SKEW.P(number1, [number2], ...)", "Population skewness.", |a: &[Arg], _c: C| num_val(skew_p( + &numbers(a)? + )?)), + f!("STANDARDIZE", 3, 3, Statistical, S, "STANDARDIZE(x, mean, standard_dev)", "Z-score of a value.", standardize), + f!( + "FREQUENCY", + 2, + 2, + Statistical, + A, + "FREQUENCY(data_array, bins_array)", + "Counts how many values fall into each bin, as a vertical array.", + frequency + ), + f!( + "PROB", + 3, + 4, + Statistical, + S_PROB, + "PROB(x_range, prob_range, lower_limit, [upper_limit])", + "Probability that values fall between two limits.", + prob + ), + f!( + "NORM.DIST", + 4, + 4, + Statistical, + S, + "NORM.DIST(x, mean, standard_dev, cumulative)", + "Normal distribution density or cumulative probability.", + norm_dist + ), + f!( + "NORMDIST", + 4, + 4, + Compatibility, + S, + "NORMDIST(x, mean, standard_dev, cumulative)", + "Normal distribution density or cumulative probability.", + norm_dist + ), + f!( + "NORM.INV", + 3, + 3, + Statistical, + S, + "NORM.INV(probability, mean, standard_dev)", + "Inverse of the normal cumulative distribution.", + norm_inv_fn + ), + f!( + "NORMINV", + 3, + 3, + Compatibility, + S, + "NORMINV(probability, mean, standard_dev)", + "Inverse of the normal cumulative distribution.", + norm_inv_fn + ), + f!("NORM.S.DIST", 2, 2, Statistical, S, "NORM.S.DIST(z, cumulative)", "Standard normal density or cumulative probability.", norm_s_dist), + f!("NORMSDIST", 1, 1, Compatibility, S, "NORMSDIST(z)", "Standard normal cumulative probability.", norm_s_dist), + f!("NORM.S.INV", 1, 1, Statistical, S, "NORM.S.INV(probability)", "Inverse of the standard normal cumulative distribution.", norm_s_inv), + f!("NORMSINV", 1, 1, Compatibility, S, "NORMSINV(probability)", "Inverse of the standard normal cumulative distribution.", norm_s_inv), + f!("T.DIST", 3, 3, Statistical, S, "T.DIST(x, deg_freedom, cumulative)", "Student's t density or left-tailed probability.", t_dist), + f!("T.DIST.2T", 2, 2, Statistical, S, "T.DIST.2T(x, deg_freedom)", "Two-tailed Student's t probability.", t_dist_2t), + f!("T.DIST.RT", 2, 2, Statistical, S, "T.DIST.RT(x, deg_freedom)", "Right-tailed Student's t probability.", t_dist_rt), + f!("TDIST", 3, 3, Compatibility, S, "TDIST(x, deg_freedom, tails)", "One- or two-tailed Student's t probability.", tdist_legacy), + f!("T.INV", 2, 2, Statistical, S, "T.INV(probability, deg_freedom)", "Left-tailed inverse of Student's t distribution.", t_inv), + f!("T.INV.2T", 2, 2, Statistical, S, "T.INV.2T(probability, deg_freedom)", "Two-tailed inverse of Student's t distribution.", t_inv_2t), + f!("TINV", 2, 2, Compatibility, S, "TINV(probability, deg_freedom)", "Two-tailed inverse of Student's t distribution.", t_inv_2t), + f!("T.TEST", 4, 4, Statistical, S_TTEST, "T.TEST(array1, array2, tails, type)", "Probability from Student's t-test.", t_test), + f!("TTEST", 4, 4, Compatibility, S_TTEST, "TTEST(array1, array2, tails, type)", "Probability from Student's t-test.", t_test), + f!( + "CHISQ.DIST", + 3, + 3, + Statistical, + S, + "CHISQ.DIST(x, deg_freedom, cumulative)", + "Chi-squared density or left-tailed probability.", + chisq_dist + ), + f!("CHISQ.DIST.RT", 2, 2, Statistical, S, "CHISQ.DIST.RT(x, deg_freedom)", "Right-tailed chi-squared probability.", chisq_dist_rt), + f!("CHIDIST", 2, 2, Compatibility, S, "CHIDIST(x, deg_freedom)", "Right-tailed chi-squared probability.", chisq_dist_rt), + f!( + "CHISQ.INV", + 2, + 2, + Statistical, + S, + "CHISQ.INV(probability, deg_freedom)", + "Inverse of the left-tailed chi-squared distribution.", + chisq_inv + ), + f!( + "CHISQ.INV.RT", + 2, + 2, + Statistical, + S, + "CHISQ.INV.RT(probability, deg_freedom)", + "Inverse of the right-tailed chi-squared distribution.", + chisq_inv_rt + ), + f!( + "CHIINV", + 2, + 2, + Compatibility, + S, + "CHIINV(probability, deg_freedom)", + "Inverse of the right-tailed chi-squared distribution.", + chisq_inv_rt + ), + f!("CHISQ.TEST", 2, 2, Statistical, A, "CHISQ.TEST(actual_range, expected_range)", "Chi-squared test of independence.", chisq_test), + f!("CHITEST", 2, 2, Compatibility, A, "CHITEST(actual_range, expected_range)", "Chi-squared test of independence.", chisq_test), + f!( + "F.DIST", + 4, + 4, + Statistical, + S, + "F.DIST(x, deg_freedom1, deg_freedom2, cumulative)", + "F distribution density or left-tailed probability.", + f_dist + ), + f!("F.DIST.RT", 3, 3, Statistical, S, "F.DIST.RT(x, deg_freedom1, deg_freedom2)", "Right-tailed F probability.", f_dist_rt), + f!("FDIST", 3, 3, Compatibility, S, "FDIST(x, deg_freedom1, deg_freedom2)", "Right-tailed F probability.", f_dist_rt), + f!("F.INV", 3, 3, Statistical, S, "F.INV(probability, deg_freedom1, deg_freedom2)", "Inverse of the left-tailed F distribution.", f_inv), + f!( + "F.INV.RT", + 3, + 3, + Statistical, + S, + "F.INV.RT(probability, deg_freedom1, deg_freedom2)", + "Inverse of the right-tailed F distribution.", + f_inv_rt + ), + f!("FINV", 3, 3, Compatibility, S, "FINV(probability, deg_freedom1, deg_freedom2)", "Inverse of the right-tailed F distribution.", f_inv_rt), + f!("F.TEST", 2, 2, Statistical, A, "F.TEST(array1, array2)", "Two-tailed probability that two variances do not differ.", f_test), + f!("FTEST", 2, 2, Compatibility, A, "FTEST(array1, array2)", "Two-tailed probability that two variances do not differ.", f_test), + f!("BINOM.DIST", 4, 4, Statistical, S, "BINOM.DIST(number_s, trials, probability_s, cumulative)", "Binomial probability.", binom_dist), + f!("BINOMDIST", 4, 4, Compatibility, S, "BINOMDIST(number_s, trials, probability_s, cumulative)", "Binomial probability.", binom_dist), + f!( + "BINOM.DIST.RANGE", + 3, + 4, + Statistical, + S, + "BINOM.DIST.RANGE(trials, probability_s, number_s, [number_s2])", + "Probability of a number of successes falling in a range.", + binom_dist_range + ), + f!( + "BINOM.INV", + 3, + 3, + Statistical, + S, + "BINOM.INV(trials, probability_s, alpha)", + "Smallest success count whose cumulative binomial probability reaches alpha.", + binom_inv + ), + f!( + "CRITBINOM", + 3, + 3, + Compatibility, + S, + "CRITBINOM(trials, probability_s, alpha)", + "Smallest success count whose cumulative binomial probability reaches alpha.", + binom_inv + ), + f!("POISSON.DIST", 3, 3, Statistical, S, "POISSON.DIST(x, mean, cumulative)", "Poisson probability.", poisson_dist), + f!("POISSON", 3, 3, Compatibility, S, "POISSON(x, mean, cumulative)", "Poisson probability.", poisson_dist), + f!("EXPON.DIST", 3, 3, Statistical, S, "EXPON.DIST(x, lambda, cumulative)", "Exponential distribution density or probability.", expon_dist), + f!("EXPONDIST", 3, 3, Compatibility, S, "EXPONDIST(x, lambda, cumulative)", "Exponential distribution density or probability.", expon_dist), + f!("GAMMA", 1, 1, Statistical, S, "GAMMA(number)", "The gamma function.", gamma_fn), + f!("GAMMA.DIST", 4, 4, Statistical, S, "GAMMA.DIST(x, alpha, beta, cumulative)", "Gamma distribution density or probability.", gamma_dist), + f!("GAMMADIST", 4, 4, Compatibility, S, "GAMMADIST(x, alpha, beta, cumulative)", "Gamma distribution density or probability.", gamma_dist), + f!("GAMMA.INV", 3, 3, Statistical, S, "GAMMA.INV(probability, alpha, beta)", "Inverse of the gamma cumulative distribution.", gamma_inv), + f!("GAMMAINV", 3, 3, Compatibility, S, "GAMMAINV(probability, alpha, beta)", "Inverse of the gamma cumulative distribution.", gamma_inv), + f!("GAMMALN", 1, 1, Statistical, S, "GAMMALN(x)", "Natural log of the gamma function.", gammaln), + f!("GAMMALN.PRECISE", 1, 1, Statistical, S, "GAMMALN.PRECISE(x)", "Natural log of the gamma function.", gammaln), + f!( + "BETA.DIST", + 4, + 6, + Statistical, + S, + "BETA.DIST(x, alpha, beta, cumulative, [A], [B])", + "Beta distribution density or probability.", + beta_dist + ), + f!("BETADIST", 3, 5, Compatibility, S, "BETADIST(x, alpha, beta, [A], [B])", "Cumulative beta probability.", betadist_legacy), + f!( + "BETA.INV", + 3, + 5, + Statistical, + S, + "BETA.INV(probability, alpha, beta, [A], [B])", + "Inverse of the cumulative beta distribution.", + beta_inv + ), + f!( + "BETAINV", + 3, + 5, + Compatibility, + S, + "BETAINV(probability, alpha, beta, [A], [B])", + "Inverse of the cumulative beta distribution.", + beta_inv + ), + f!( + "LOGNORM.DIST", + 4, + 4, + Statistical, + S, + "LOGNORM.DIST(x, mean, standard_dev, cumulative)", + "Lognormal density or probability.", + lognorm_dist + ), + f!("LOGNORMDIST", 3, 3, Compatibility, S, "LOGNORMDIST(x, mean, standard_dev)", "Cumulative lognormal probability.", lognorm_dist), + f!( + "LOGNORM.INV", + 3, + 3, + Statistical, + S, + "LOGNORM.INV(probability, mean, standard_dev)", + "Inverse of the cumulative lognormal distribution.", + lognorm_inv + ), + f!( + "LOGINV", + 3, + 3, + Compatibility, + S, + "LOGINV(probability, mean, standard_dev)", + "Inverse of the cumulative lognormal distribution.", + lognorm_inv + ), + f!("WEIBULL.DIST", 4, 4, Statistical, S, "WEIBULL.DIST(x, alpha, beta, cumulative)", "Weibull density or probability.", weibull_dist), + f!("WEIBULL", 4, 4, Compatibility, S, "WEIBULL(x, alpha, beta, cumulative)", "Weibull density or probability.", weibull_dist), + f!( + "HYPGEOM.DIST", + 5, + 5, + Statistical, + S, + "HYPGEOM.DIST(sample_s, number_sample, population_s, number_pop, cumulative)", + "Hypergeometric probability.", + hypgeom_dist + ), + f!( + "HYPGEOMDIST", + 4, + 4, + Compatibility, + S, + "HYPGEOMDIST(sample_s, number_sample, population_s, number_pop)", + "Hypergeometric probability of an exact count.", + hypgeom_dist + ), + f!( + "NEGBINOM.DIST", + 4, + 4, + Statistical, + S, + "NEGBINOM.DIST(number_f, number_s, probability_s, cumulative)", + "Negative binomial probability.", + negbinom_dist + ), + f!( + "NEGBINOMDIST", + 3, + 3, + Compatibility, + S, + "NEGBINOMDIST(number_f, number_s, probability_s)", + "Negative binomial probability.", + negbinom_dist + ), + f!( + "CONFIDENCE", + 3, + 3, + Compatibility, + S, + "CONFIDENCE(alpha, standard_dev, size)", + "Confidence interval half-width using the normal distribution.", + confidence_norm + ), + f!( + "CONFIDENCE.NORM", + 3, + 3, + Statistical, + S, + "CONFIDENCE.NORM(alpha, standard_dev, size)", + "Confidence interval half-width using the normal distribution.", + confidence_norm + ), + f!( + "CONFIDENCE.T", + 3, + 3, + Statistical, + S, + "CONFIDENCE.T(alpha, standard_dev, size)", + "Confidence interval half-width using Student's t distribution.", + confidence_t + ), + f!("Z.TEST", 2, 3, Statistical, S_PRANK, "Z.TEST(array, x, [sigma])", "One-tailed probability of a z-test.", z_test), + f!("ZTEST", 2, 3, Compatibility, S_PRANK, "ZTEST(array, x, [sigma])", "One-tailed probability of a z-test.", z_test), + f!("FISHER", 1, 1, Statistical, S, "FISHER(x)", "Fisher transformation.", fisher), + f!("FISHERINV", 1, 1, Statistical, S, "FISHERINV(y)", "Inverse of the Fisher transformation.", |a: &[Arg], _c: C| num_val(num(a, 0)?.tanh())), + f!("PHI", 1, 1, Statistical, S, "PHI(x)", "Standard normal density.", |a: &[Arg], _c: C| num_val(norm_pdf(num(a, 0)?))), + f!("GAUSS", 1, 1, Statistical, S, "GAUSS(z)", "Probability that a standard normal value falls between the mean and z.", gauss), + ] +} + +#[cfg(test)] +mod tests { + use super::{aggregate_values, aggregate_values_k}; + use crate::util::testutil::*; + use sheetcraft_core::{CellError, Value}; + + const D7: f64 = 1e-6; + + fn c(xs: &[f64]) -> crate::Arg { + av(col(xs)) + } + + #[test] + fn averages_and_counts() { + close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); + is_err(ev("AVERAGE", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Div0); + let mixed = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(3.0)]]); + close(ev("AVERAGE", vec![rf(mixed.clone())]), 2.0); + close(ev("AVERAGEA", vec![rf(mixed.clone())]), 1.25); + close(ev("AVERAGE", vec![t("4"), b(true)]), 2.5); + is_err(ev("AVERAGE", vec![t("abc")]), CellError::Value); + is_err(ev("AVERAGE", vec![rf(arr(vec![vec![nv(1.0), Value::Error(CellError::NA)]]))]), CellError::NA); + close(ev("COUNT", vec![rf(mixed.clone()), n(4.0), t("5"), t("x")]), 4.0); + close(ev("COUNT", vec![e(CellError::Div0)]), 0.0); + close(ev("COUNTA", vec![rf(mixed.clone())]), 4.0); + close(ev("COUNTA", vec![n(1.0), t("")]), 2.0); + let blanks = arr(vec![vec![Value::Empty, tv(""), nv(0.0), tv(" ")]]); + close(ev("COUNTBLANK", vec![rf(blanks)]), 2.0); + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[3.0, 1.0, 2.0])]), 2.0); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Num); + close(ev("MODE.SNGL", vec![c(&[5.6, 4.0, 4.0, 3.0, 2.0, 4.0])]), 4.0); + close(ev("MODE", vec![c(&[1.0, 2.0, 2.0, 1.0])]), 1.0); + is_err(ev("MODE", vec![c(&[1.0, 2.0, 3.0])]), CellError::NA); + assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); + } + + #[test] + fn spread() { + let d = [1345.0, 1301.0, 1368.0, 1322.0, 1310.0, 1370.0, 1318.0, 1350.0, 1303.0, 1299.0]; + close_tol(ev("STDEV.S", vec![c(&d)]), 27.46391572, 1e-9); + close_tol(ev("STDEV", vec![c(&d)]), 27.46391572, 1e-9); + close_tol(ev("STDEV.P", vec![c(&d)]), 26.05455814, 1e-9); + close_tol(ev("STDEVP", vec![c(&d)]), 26.05455814, 1e-9); + close_tol(ev("VAR.S", vec![c(&d)]), 754.2666667, 1e-9); + close(ev("VAR.P", vec![c(&d)]), 678.84); + close(ev("VARP", vec![c(&d)]), 678.84); + is_err(ev("STDEV.S", vec![n(1.0)]), CellError::Div0); + close(ev("STDEV.P", vec![n(1.0)]), 0.0); + let mixed = arr(vec![vec![nv(2.0), Value::Bool(true), tv("x")]]); + close(ev("VARA", vec![rf(mixed.clone())]), 1.0); + close(ev("VARPA", vec![rf(mixed.clone())]), 2.0 / 3.0); + close(ev("STDEVA", vec![rf(mixed.clone())]), 1.0); + close(ev("STDEVPA", vec![rf(mixed)]), (2.0f64 / 3.0).sqrt()); + let s = [4.0, 5.0, 6.0, 7.0, 5.0, 4.0, 3.0]; + close(ev("DEVSQ", vec![c(&[4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0])]), 48.0); + close_tol(ev("AVEDEV", vec![c(&s)]), 1.020408163, 1e-9); + let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0]; + close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9); + close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9); + is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num); + close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9); + is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num); + let k = [3.0, 4.0, 5.0, 2.0, 3.0, 4.0, 5.0, 6.0, 4.0, 7.0]; + close_tol(ev("KURT", vec![c(&k)]), -0.151799637, 1e-8); + close_tol(ev("SKEW", vec![c(&k)]), 0.359543071, 1e-8); + close_tol(ev("SKEW.P", vec![c(&k)]), 0.303193339, 1e-8); + is_err(ev("KURT", vec![c(&[1.0, 2.0, 3.0])]), CellError::Div0); + close_tol(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(1.5)]), 1.333333333, 1e-9); + is_err(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(0.0)]), CellError::Num); + } + + #[test] + fn min_max_order() { + close(ev("MIN", vec![c(&[3.0, -1.0, 2.0]), n(0.0)]), -1.0); + close(ev("MAX", vec![c(&[3.0, -1.0, 2.0]), n(10.0)]), 10.0); + close(ev("MAX", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); + close(ev("MAXA", vec![rf(arr(vec![vec![nv(-1.0), Value::Bool(true)]]))]), 1.0); + close(ev("MINA", vec![rf(arr(vec![vec![nv(1.0), tv("x")]]))]), 0.0); + let d = c(&[3.0, 5.0, 3.0, 5.0, 4.0, 4.0, 2.0, 4.0, 6.0, 7.0]); + close(ev("LARGE", vec![d.clone(), n(3.0)]), 5.0); + close(ev("SMALL", vec![d.clone(), n(4.0)]), 4.0); + is_err(ev("LARGE", vec![d.clone(), n(0.0)]), CellError::Num); + is_err(ev("SMALL", vec![d.clone(), n(11.0)]), CellError::Num); + assert_eq!(rows_of(&ev("LARGE", vec![d, av(row(&[1.0, 2.0]))])), vec![vec![nv(7.0), nv(6.0)]]); + let r = c(&[89.0, 88.0, 92.0, 101.0, 94.0, 97.0, 95.0]); + close(ev("RANK.AVG", vec![n(94.0), r.clone()]), 4.0); + close(ev("RANK.EQ", vec![n(101.0), r.clone()]), 1.0); + is_err(ev("RANK", vec![n(1.0), r]), CellError::NA); + let r2 = c(&[7.0, 3.5, 3.5, 1.0, 2.0]); + close(ev("RANK.EQ", vec![n(7.0), r2.clone(), n(1.0)]), 5.0); + close(ev("RANK.EQ", vec![n(3.5), r2.clone(), n(1.0)]), 3.0); + close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5); + } + + #[test] + fn percentiles() { + close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9); + close(ev("PERCENTILE", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(1.0)]), 4.0); + is_err(ev("PERCENTILE.INC", vec![c(&[1.0]), n(1.5)]), CellError::Num); + let e9 = c(&[1.0, 2.0, 3.0, 6.0, 6.0, 6.0, 7.0, 8.0, 9.0]); + close(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.25)]), 2.5); + is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.0)]), CellError::Num); + is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.01)]), CellError::Num); + close(ev("QUARTILE.INC", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(1.0)]), 3.5); + close(ev("QUARTILE", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(4.0)]), 12.0); + let q = c(&[6.0, 7.0, 15.0, 36.0, 39.0, 40.0, 41.0, 42.0, 43.0, 47.0, 49.0]); + close(ev("QUARTILE.EXC", vec![q.clone(), n(1.0)]), 15.0); + close(ev("QUARTILE.EXC", vec![q.clone(), n(3.0)]), 43.0); + is_err(ev("QUARTILE.EXC", vec![q, n(4.0)]), CellError::Num); + let p = c(&[13.0, 12.0, 11.0, 8.0, 4.0, 3.0, 2.0, 1.0, 1.0, 1.0]); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(2.0)]), 0.333); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(4.0)]), 0.555); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(8.0)]), 0.666); + close(ev("PERCENTRANK", vec![p.clone(), n(5.0)]), 0.583); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(5.0), n(1.0)]), 0.5); + is_err(ev("PERCENTRANK.INC", vec![p.clone(), n(0.5)]), CellError::NA); + is_err(ev("PERCENTRANK.INC", vec![p, n(5.0), n(0.0)]), CellError::Num); + close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7); + close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); + close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); + } + + #[test] + fn pairs_and_regression() { + close_tol(ev("CORREL", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 0.997054486, 1e-9); + close_tol(ev("PEARSON", vec![c(&[9.0, 7.0, 5.0, 3.0, 1.0]), c(&[10.0, 6.0, 1.0, 5.0, 3.0])]), 0.699379, D7); + is_err(ev("CORREL", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::NA); + is_err(ev("CORREL", vec![c(&[1.0, 1.0]), c(&[1.0, 2.0])]), CellError::Div0); + let y = c(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); + let x = c(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); + close_tol(ev("SLOPE", vec![y.clone(), x.clone()]), 0.305555556, 1e-8); + close_tol(ev("RSQ", vec![y.clone(), x.clone()]), 0.057950192, 1e-8); + close_tol(ev("STEYX", vec![y.clone(), x.clone()]), 3.305718950, 1e-8); + close_tol(ev("INTERCEPT", vec![c(&[2.0, 3.0, 9.0, 1.0, 8.0]), c(&[6.0, 5.0, 11.0, 7.0, 5.0])]), 0.048387097, 1e-8); + close_tol(ev("FORECAST", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); + close_tol(ev("FORECAST.LINEAR", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); + close(ev("COVARIANCE.P", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); + close(ev("COVAR", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); + close(ev("COVARIANCE.S", vec![c(&[2.0, 4.0, 8.0]), c(&[5.0, 11.0, 12.0])]), 29.0 / 3.0); + // Pairs with text are skipped. + let xs = arr(vec![vec![nv(1.0)], vec![tv("x")], vec![nv(3.0)]]); + close(ev("SLOPE", vec![c(&[2.0, 100.0, 6.0]), rf(xs)]), 2.0); + } + + #[test] + fn linest_family() { + let r = rows_of(&ev("LINEST", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); + assert_eq!(r.len(), 1); + close(r[0][0].clone(), 2.0); + close(r[0][1].clone(), 1.0); + // Statistics for a simple fit: y = 1,2,4 on x = 1,2,3. + let s = rows_of(&ev("LINEST", vec![c(&[1.0, 2.0, 4.0]), c(&[1.0, 2.0, 3.0]), b(true), b(true)])); + assert_eq!((s.len(), s[0].len()), (5, 2)); + close(s[0][0].clone(), 1.5); + close(s[0][1].clone(), -2.0 / 3.0); + close(s[2][0].clone(), 0.9642857142857143); // r² + close(s[3][1].clone(), 1.0); // df + close(s[4][0].clone(), 4.5); // ssreg + close(s[4][1].clone(), 1.0 / 6.0); // ssresid + close(s[2][1].clone(), (1.0f64 / 6.0).sqrt()); // sey + close(s[3][0].clone(), 27.0); // F + close(s[1][0].clone(), (1.0f64 / 6.0 / 2.0).sqrt()); // se slope + // No constant. + let z = rows_of(&ev("LINEST", vec![c(&[2.0, 4.0, 6.0]), c(&[1.0, 2.0, 3.0]), b(false)])); + close(z[0][0].clone(), 2.0); + close(z[0][1].clone(), 0.0); + // Two predictors: y = 1 + 2a + 3b. + let xs = arr(vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)], vec![nv(1.0), nv(1.0)], vec![nv(2.0), nv(1.0)], vec![nv(3.0), nv(5.0)]]); + let ys: Vec = [(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 1.0), (3.0, 5.0)].iter().map(|(a, b)| 1.0 + 2.0 * a + 3.0 * b).collect(); + let m = rows_of(&ev("LINEST", vec![c(&ys), av(xs)])); + close(m[0][0].clone(), 3.0); + close(m[0][1].clone(), 2.0); + close(m[0][2].clone(), 1.0); + let gy = [33100.0, 47300.0, 69000.0, 102000.0, 150000.0, 220000.0]; + let gx = [11.0, 12.0, 13.0, 14.0, 15.0, 16.0]; + let l = rows_of(&ev("LOGEST", vec![c(&gy), c(&gx)])); + close_tol(l[0][0].clone(), 1.463275628, 1e-9); + close_tol(l[0][1].clone(), 495.3047702, 1e-9); + let g = rows_of(&ev("GROWTH", vec![c(&gy), c(&gx), c(&[17.0, 18.0])])); + close_tol(g[0][0].clone(), 320196.7184, 1e-9); + close_tol(g[1][0].clone(), 468536.0539, 1e-9); + let ty = [133890.0, 135000.0, 135790.0, 137300.0, 138130.0, 139100.0, 139900.0, 141120.0, 141890.0, 143230.0, 144000.0, 145290.0]; + let tx: Vec = (1..=12).map(f64::from).collect(); + let t1 = ev("TREND", vec![c(&ty), c(&tx), n(13.0)]); + close_tol(Value::Number(rows_of(&t1)[0][0].as_f64().unwrap().round()), 146172.0, 1e-9); + let fitted = rows_of(&ev("TREND", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); + close(fitted[1][0].clone(), 9.0); + is_err(ev("LOGEST", vec![c(&[1.0, -2.0])]), CellError::Num); + is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref); + } + + #[test] + fn frequency_prob() { + let f = ev("FREQUENCY", vec![c(&[79.0, 85.0, 78.0, 85.0, 50.0, 81.0, 95.0, 88.0, 97.0]), c(&[70.0, 79.0, 89.0])]); + assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(4.0)], vec![nv(2.0)]]); + let x = c(&[0.0, 1.0, 2.0, 3.0]); + let p = c(&[0.2, 0.3, 0.1, 0.4]); + close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1); + close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8); + is_err(ev("PROB", vec![x, c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num); + } + + #[test] + fn normal() { + close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); + close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(false)]), 0.10934005, D7); + close_tol(ev("NORMDIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); + is_err(ev("NORM.DIST", vec![n(42.0), n(40.0), n(0.0), b(true)]), CellError::Num); + close_tol(ev("NORM.INV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); + close_tol(ev("NORMINV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); + is_err(ev("NORM.INV", vec![n(1.0), n(0.0), n(1.0)]), CellError::Num); + close_tol(ev("NORM.S.DIST", vec![n(1.333333), b(true)]), 0.908788726, 1e-8); + close_tol(ev("NORM.S.DIST", vec![n(0.0), b(false)]), 0.398942280, 1e-8); + close_tol(ev("NORMSDIST", vec![n(1.333333)]), 0.908788726, 1e-8); + close_tol(ev("NORM.S.INV", vec![n(0.908789)]), 1.3333347, D7); + close_tol(ev("NORMSINV", vec![n(0.975)]), 1.959963985, 1e-9); + close_tol(ev("PHI", vec![n(0.75)]), 0.301137432, 1e-8); + close_tol(ev("GAUSS", vec![n(2.0)]), 0.477249868, 1e-8); + close_tol(ev("FISHER", vec![n(0.75)]), 0.972955075, 1e-8); + close_tol(ev("FISHERINV", vec![n(0.972955)]), 0.75, D7); + is_err(ev("FISHER", vec![n(1.0)]), CellError::Num); + close_tol(ev("CONFIDENCE.NORM", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); + close_tol(ev("CONFIDENCE", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); + close_tol(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(50.0)]), 0.284196855, 1e-8); + is_err(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(1.0)]), CellError::Div0); + let z = c(&[3.0, 6.0, 7.0, 8.0, 6.0, 5.0, 4.0, 2.0, 1.0, 9.0]); + close_tol(ev("Z.TEST", vec![z.clone(), n(4.0)]), 0.090574, 1e-5); + close_tol(ev("ZTEST", vec![z, n(6.0)]), 0.863043, 1e-5); + close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(true)]), 0.0390836, D7); + close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(false)]), 0.0176176, D7); + close_tol(ev("LOGNORMDIST", vec![n(4.0), n(3.5), n(1.2)]), 0.0390836, D7); + close_tol(ev("LOGNORM.INV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); + close_tol(ev("LOGINV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); + } + + #[test] + fn t_chi_f() { + close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7); + close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5); + close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7); + close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); + close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); + is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); + is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); + close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); + close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); + close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); + close_tol(ev("T.INV.2T", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); + close_tol(ev("TINV", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); + let a1 = c(&[3.0, 4.0, 5.0, 8.0, 9.0, 1.0, 2.0, 4.0, 5.0]); + let a2 = c(&[6.0, 19.0, 3.0, 2.0, 14.0, 4.0, 5.0, 17.0, 1.0]); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); + close_tol(ev("TTEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(2.0)]), 0.191995887, 1e-6); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(1.0), n(3.0)]), 0.1012, 1e-3); + is_err(ev("T.TEST", vec![a1, a2, n(3.0), n(1.0)]), CellError::Num); + close_tol(ev("CHISQ.DIST", vec![n(0.5), n(1.0), b(true)]), 0.52049988, D7); + close_tol(ev("CHISQ.DIST", vec![n(2.0), n(3.0), b(false)]), 0.20755375, D7); + close_tol(ev("CHISQ.DIST.RT", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); + close_tol(ev("CHIDIST", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); + close_tol(ev("CHISQ.INV", vec![n(0.93), n(1.0)]), 3.283020287, 1e-8); + close_tol(ev("CHISQ.INV", vec![n(0.6), n(2.0)]), 1.832581464, 1e-8); + close_tol(ev("CHISQ.INV.RT", vec![n(0.050001), n(10.0)]), 18.30697, D7); + close_tol(ev("CHIINV", vec![n(0.050001), n(10.0)]), 18.30697, D7); + is_err(ev("CHISQ.DIST", vec![n(-1.0), n(1.0), b(true)]), CellError::Num); + let act = arr(vec![vec![nv(58.0), nv(35.0)], vec![nv(11.0), nv(25.0)], vec![nv(10.0), nv(23.0)]]); + let exp = arr(vec![vec![nv(45.35), nv(47.65)], vec![nv(17.56), nv(18.44)], vec![nv(16.09), nv(16.91)]]); + close_tol(ev("CHISQ.TEST", vec![av(act.clone()), av(exp.clone())]), 0.0003082, 1e-3); + close_tol(ev("CHITEST", vec![av(act), av(exp)]), 0.0003082, 1e-3); + close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(true)]), 0.99, 1e-6); + close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(false)]), 0.0012238, 1e-5); + close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); + close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); + close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7); + close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); + close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); + close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); + close_tol(ev("FTEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); + is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); + } + + #[test] + fn discrete() { + close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); + close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12); + close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); + is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num); + close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6); + close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6); + close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0); + close(ev("CRITBINOM", vec![n(6.0), n(0.5), n(0.75)]), 4.0); + close(ev("BINOM.INV", vec![n(1e9), n(0.5), n(0.5)]), 5e8); + close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(true)]), 0.124652, 1e-5); + close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); + close_tol(ev("POISSON", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); + is_err(ev("POISSON.DIST", vec![n(-1.0), n(5.0), b(false)]), CellError::Num); + close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(true)]), 0.4654, 1e-4); + close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(false)]), 0.3633, 1e-4); + close_tol(ev("HYPGEOMDIST", vec![n(1.0), n(4.0), n(8.0), n(20.0)]), 0.363261094, 1e-8); + is_err(ev("HYPGEOMDIST", vec![n(5.0), n(4.0), n(8.0), n(20.0)]), CellError::Num); + close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(true)]), 0.3135141, D7); + close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(false)]), 0.0550487, D7); + close_tol(ev("NEGBINOMDIST", vec![n(10.0), n(5.0), n(0.25)]), 0.0550487, D7); + } + + #[test] + fn continuous() { + close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); + close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(false)]), 1.35335283, D7); + close_tol(ev("EXPONDIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); + is_err(ev("EXPON.DIST", vec![n(0.2), n(0.0), b(true)]), CellError::Num); + close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(false)]), 0.032639, 1e-5); + close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); + close_tol(ev("GAMMADIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); + close_tol(ev("GAMMA.INV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); + close_tol(ev("GAMMAINV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); + close_tol(ev("GAMMALN", vec![n(4.0)]), 1.791759469, 1e-9); + close_tol(ev("GAMMALN.PRECISE", vec![n(4.5)]), 2.453736571, 1e-9); + is_err(ev("GAMMALN", vec![n(0.0)]), CellError::Num); + close_tol(ev("GAMMA", vec![n(2.5)]), 1.329340388, 1e-9); + close_tol(ev("GAMMA", vec![n(-0.75)]), -4.834146544, 1e-8); + is_err(ev("GAMMA", vec![n(-1.0)]), CellError::Num); + is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num); + close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7); + close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7); + close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7); + close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); + close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); + is_err(ev("BETA.DIST", vec![n(4.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), CellError::Num); + close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); + close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(false)]), 0.035589, 1e-4); + close_tol(ev("WEIBULL", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); + // Lifting over an array of x values. + let r = rows_of(&ev("NORM.S.DIST", vec![av(row(&[0.0, 1.0])), b(true)])); + close(r[0][0].clone(), 0.5); + close_tol(r[0][1].clone(), 0.841344746, 1e-8); + } + + #[test] + fn aggregate_helper() { + let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; + close(aggregate_values(1, &vals), 3.0); + close(aggregate_values(2, &vals), 3.0); + close(aggregate_values(3, &vals), 5.0); + close(aggregate_values(4, &vals), 6.0); + close(aggregate_values(105, &vals), 1.0); + close(aggregate_values(6, &vals), 12.0); + close(aggregate_values(9, &vals), 9.0); + close(aggregate_values(109, &vals), 9.0); + close(aggregate_values(11, &vals), 14.0 / 3.0); + close(aggregate_values(10, &vals), 7.0); + close(aggregate_values(12, &vals), 2.0); + is_err(aggregate_values(13, &vals), CellError::NA); + is_err(aggregate_values(14, &vals), CellError::Value); + is_err(aggregate_values(99, &vals), CellError::Value); + is_err(aggregate_values(9, &[nv(1.0), Value::Error(CellError::Div0)]), CellError::Div0); + close(aggregate_values_k(14, &vals, &nv(1.0)), 6.0); + close(aggregate_values_k(15, &vals, &nv(2.0)), 2.0); + close(aggregate_values_k(16, &vals, &nv(0.5)), 2.0); + close(aggregate_values_k(17, &vals, &nv(4.0)), 6.0); + close(aggregate_values_k(18, &vals, &nv(0.5)), 2.0); + close(aggregate_values_k(19, &vals, &nv(2.0)), 2.0); + close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0); + is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num); + } +} diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs new file mode 100644 index 00000000..d5f7ab84 --- /dev/null +++ b/crates/functions/src/text.rs @@ -0,0 +1,1043 @@ +//! Text functions. + +use sheetcraft_core::parse::parse_number_text; +use sheetcraft_core::{CellError, Value, number_to_text}; + +use crate::criteria::wildcard_find; +use crate::util::{ + A, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text, text_val, +}; +use crate::{Arg, Ctx, FnSpec, VAR}; + +const TJ: &[bool] = &[false, true, false]; +const TB: &[bool] = &[true, false, true, true, true, false]; +const TS: &[bool] = &[true, false, false, true, true, false]; +const ATT: &[bool] = &[false, true]; + +fn chars(s: &str) -> Vec { + s.chars().collect() +} + +fn check_len(s: String) -> R { + if s.len() > MAX_TEXT && s.chars().count() > MAX_TEXT { + return Err(CellError::Value); + } + text_val(s) +} + +/// Pushes `piece` onto `out`, failing once the result exceeds the cell limit. +fn push_capped(out: &mut String, piece: &str) -> R<()> { + out.push_str(piece); + if out.len() > MAX_TEXT * 4 { + return Err(CellError::Value); + } + Ok(()) +} + +fn concatenate(a: &[Arg], _c: &mut dyn Ctx) -> R { + let mut out = String::new(); + for x in a { + push_capped(&mut out, &scalar(x).to_text()?)?; + } + check_len(out) +} + +fn concat(a: &[Arg], _c: &mut dyn Ctx) -> R { + let mut out = String::new(); + for x in a { + for v in as_array(&x.value).iter() { + push_capped(&mut out, &v.to_text()?)?; + } + } + check_len(out) +} + +fn textjoin(a: &[Arg], _c: &mut dyn Ctx) -> R { + let delims: Vec = as_array(&arg(a, 0)?.value).iter().map(|v| v.to_text()).collect::>()?; + let ignore = boolean(a, 1)?; + let mut items = Vec::new(); + for x in a.iter().skip(2) { + for v in as_array(&x.value).iter() { + let s = v.to_text()?; + if ignore && s.is_empty() { + continue; + } + items.push(s); + } + } + let mut out = String::new(); + for (i, s) in items.iter().enumerate() { + if i > 0 && !delims.is_empty() { + push_capped(&mut out, &delims[(i - 1) % delims.len()])?; + } + push_capped(&mut out, s)?; + } + check_len(out) +} + +fn count_arg(a: &[Arg], i: usize, default: f64) -> R { + let n = opt_num(a, i, default)?; + if n < 0.0 || n.is_nan() { + return Err(CellError::Value); + } + Ok(if n > 1e9 { 1_000_000_000 } else { n.trunc() as usize }) +} + +fn left(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let n = count_arg(a, 1, 1.0)?; + text_val(s.chars().take(n).collect::()) +} + +fn right(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = chars(&text(a, 0)?); + let n = count_arg(a, 1, 1.0)?.min(s.len()); + text_val(s[s.len() - n..].iter().collect::()) +} + +fn mid(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let start = num(a, 1)?; + if start.is_nan() || start < 1.0 { + return Err(CellError::Value); + } + let n = count_arg(a, 2, 0.0)?; + let start = if start > 1e9 { 1_000_000_000 } else { start as usize }; + text_val(s.chars().skip(start - 1).take(n).collect::()) +} + +fn len(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(text(a, 0)?.chars().count() as f64) +} + +fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R { + text_val(text(a, 0)?.to_lowercase()) +} + +fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R { + text_val(text(a, 0)?.to_uppercase()) +} + +fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let mut out = String::with_capacity(s.len()); + let mut prev_letter = false; + for ch in s.chars() { + if ch.is_alphabetic() { + if prev_letter { + out.extend(ch.to_lowercase()); + } else { + out.extend(ch.to_uppercase()); + } + prev_letter = true; + } else { + out.push(ch); + prev_letter = false; + } + } + text_val(out) +} + +fn trim(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + text_val(s.split(' ').filter(|w| !w.is_empty()).collect::>().join(" ")) +} + +fn clean(a: &[Arg], _c: &mut dyn Ctx) -> R { + text_val(text(a, 0)?.chars().filter(|c| (*c as u32) >= 32).collect::()) +} + +fn substitute(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let old = text(a, 1)?; + let new = text(a, 2)?; + if old.is_empty() { + return text_val(s); + } + if has(a, 3) { + let inst = num(a, 3)?; + if inst.is_nan() || inst < 1.0 { + return Err(CellError::Value); + } + let inst = inst.trunc() as usize; + match s.match_indices(old.as_str()).nth(inst - 1) { + Some((pos, _)) => { + let mut out = String::with_capacity(s.len() + new.len()); + out.push_str(&s[..pos]); + out.push_str(&new); + out.push_str(&s[pos + old.len()..]); + check_len(out) + } + None => text_val(s), + } + } else { + let count = s.matches(old.as_str()).count(); + if new.len() > old.len() && s.len() + count.saturating_mul(new.len() - old.len()) > MAX_TEXT * 4 { + return Err(CellError::Value); + } + check_len(s.replace(old.as_str(), &new)) + } +} + +fn replace(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = chars(&text(a, 0)?); + let start = num(a, 1)?; + if start.is_nan() || start < 1.0 { + return Err(CellError::Value); + } + let n = count_arg(a, 2, 0.0)?; + let new = text(a, 3)?; + let start = (if start > 1e9 { 1_000_000_000 } else { start as usize } - 1).min(s.len()); + let end = start.saturating_add(n).min(s.len()); + let mut out: String = s[..start].iter().collect(); + out.push_str(&new); + out.extend(s[end..].iter()); + check_len(out) +} + +fn rept(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let n = num(a, 1)?; + if n.is_nan() || n < 0.0 { + return Err(CellError::Value); + } + let n = n.trunc(); + let total = s.chars().count() as f64 * n; + if total > MAX_TEXT as f64 { + return Err(CellError::Value); + } + text_val(s.repeat(n as usize)) +} + +fn find_impl(a: &[Arg], wild: bool) -> R { + let needle = text(a, 0)?; + let hay = text(a, 1)?; + let start = opt_num(a, 2, 1.0)?; + let hc = chars(&hay); + if (start.is_nan() || start < 1.0) || start > hc.len() as f64 + 1.0 { + return Err(CellError::Value); + } + let start = start as usize - 1; + if needle.is_empty() { + return num_val((start + 1) as f64); + } + let pos = if wild { + wildcard_find(&needle, &hay, start) + } else { + let nc = chars(&needle); + (start..hc.len()).find(|&i| hc.get(i..i + nc.len()).is_some_and(|w| w == nc.as_slice())) + }; + match pos { + Some(p) => num_val((p + 1) as f64), + None => Err(CellError::Value), + } +} + +fn find(a: &[Arg], _c: &mut dyn Ctx) -> R { + find_impl(a, false) +} + +fn search(a: &[Arg], _c: &mut dyn Ctx) -> R { + find_impl(a, true) +} + +fn exact(a: &[Arg], _c: &mut dyn Ctx) -> R { + Ok(Value::Bool(text(a, 0)? == text(a, 1)?)) +} + +/// Windows-1252 code points 128–159. +const CP1252: [u32; 32] = [ + 0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019, + 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178, +]; + +fn char_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + if !(1.0..=255.0).contains(&n) { + return Err(CellError::Value); + } + let n = n as u32; + let cp = if (128..160).contains(&n) { CP1252[(n - 128) as usize] } else { n }; + text_val(char::from_u32(cp).map(String::from).unwrap_or_default()) +} + +fn code(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let ch = s.chars().next().ok_or(CellError::Value)?; + let cp = ch as u32; + let code = if cp < 128 || (160..256).contains(&cp) { + cp + } else if let Some(i) = CP1252.iter().position(|&x| x == cp) { + 128 + i as u32 + } else { + 63 + }; + num_val(code as f64) +} + +fn unichar(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + if !(1.0..=1_114_111.0).contains(&n) { + return Err(CellError::Value); + } + match char::from_u32(n as u32) { + Some(c) => text_val(c.to_string()), + None => Err(CellError::NA), + } +} + +fn unicode(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let ch = s.chars().next().ok_or(CellError::Value)?; + num_val(ch as u32 as f64) +} + +fn value(a: &[Arg], _c: &mut dyn Ctx) -> R { + match scalar(arg(a, 0)?) { + Value::Number(n) => num_val(n), + Value::Empty => num_val(0.0), + Value::Error(e) => Err(e), + Value::Text(t) => { + if t.trim().is_empty() { + return num_val(0.0); + } + parse_number_text(&t).ok_or(CellError::Value).and_then(num_val) + } + _ => Err(CellError::Value), + } +} + +fn numbervalue(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let dec = if a.len() > 1 { text(a, 1)?.chars().next().ok_or(CellError::Value)? } else { '.' }; + let grp = if a.len() > 2 { text(a, 2)?.chars().next().ok_or(CellError::Value)? } else { ',' }; + if dec == grp { + return Err(CellError::Value); + } + let mut body: String = s.chars().filter(|c| !c.is_whitespace()).collect(); + if body.is_empty() { + return num_val(0.0); + } + let mut pct = 0; + while let Some(b) = body.strip_suffix('%') { + body = b.to_string(); + pct += 1; + } + let mut seen_dec = false; + let mut out = String::new(); + for c in body.chars() { + if c == dec { + if seen_dec { + return Err(CellError::Value); + } + seen_dec = true; + out.push('.'); + } else if c == grp { + if seen_dec { + return Err(CellError::Value); + } + } else { + out.push(c); + } + } + let mut n = parse_number_text(&out).ok_or(CellError::Value)?; + for _ in 0..pct { + n /= 100.0; + } + num_val(n) +} + +/// Formats `x` (already rounded) with `decimals` places and optional thousands separators. +fn format_fixed(x: f64, decimals: i64, commas: bool) -> String { + let d = decimals.max(0) as usize; + let s = format!("{:.*}", d, x.abs()); + let (int_part, frac) = match s.split_once('.') { + Some((i, f)) => (i.to_string(), Some(f.to_string())), + None => (s.clone(), None), + }; + let int_part = if commas { + let b = int_part.as_bytes(); + let mut o = String::new(); + for (i, ch) in b.iter().enumerate() { + if i > 0 && (b.len() - i) % 3 == 0 { + o.push(','); + } + o.push(char::from(*ch)); + } + o + } else { + int_part + }; + let mut out = int_part; + if let Some(f) = frac { + out.push('.'); + out.push_str(&f); + } + out +} + +fn rounded(a: &[Arg], default_dec: f64) -> R<(f64, i64)> { + let x = num(a, 0)?; + let d = opt_num(a, 1, default_dec)?.trunc(); + if d > 127.0 { + return Err(CellError::Value); + } + let d = d.max(-308.0) as i64; + let r = round_half_away(x, d as i32); + if !r.is_finite() || r.abs() >= 1e300 { + return Err(CellError::Num); + } + Ok((r, d)) +} + +fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (r, d) = rounded(a, 2.0)?; + let no_commas = opt_bool(a, 2, false)?; + let body = format_fixed(r, d, !no_commas); + text_val(if r < 0.0 { format!("-{body}") } else { body }) +} + +fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (r, d) = rounded(a, 2.0)?; + let body = format_fixed(r, d, true); + text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") }) +} + +fn t_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { + match scalar(arg(a, 0)?) { + Value::Text(t) => Ok(Value::Text(t)), + Value::Error(e) => Err(e), + _ => text_val(""), + } +} + +fn identity(a: &[Arg], _c: &mut dyn Ctx) -> R { + text_val(text(a, 0)?) +} + +/// Lower-cased char sequence with one char per input char (keeps positions aligned). +fn fold(c: &[char]) -> Vec { + c.iter().map(|ch| ch.to_lowercase().next().unwrap_or(*ch)).collect() +} + +/// Delimiter list from a scalar or array argument. +fn delimiters(v: &Value) -> R>> { + as_array(v).iter().map(|d| d.to_text().map(|s| chars(&s))).collect() +} + +/// Non-overlapping delimiter matches (start, end) in char positions, scanning left to right and +/// preferring the longest delimiter at each position. Empty delimiters are ignored unless all +/// delimiters are empty, in which case every position matches with zero length. +fn find_matches(text: &[char], delims: &[Vec], insensitive: bool) -> Vec<(usize, usize)> { + let t = if insensitive { fold(text) } else { text.to_vec() }; + let ds: Vec> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect(); + if ds.is_empty() { + return (0..=t.len()).map(|i| (i, i)).collect(); + } + let mut out = Vec::new(); + let mut i = 0; + while i < t.len() { + let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max(); + match best { + Some(l) => { + out.push((i, i + l)); + i += l; + } + None => i += 1, + } + } + out +} + +fn text_before_after(a: &[Arg], before: bool) -> R { + let s = chars(&text(a, 0)?); + let delims = delimiters(&arg(a, 1)?.value)?; + let inst = opt_int(a, 2, 1)?; + let mode = opt_int(a, 3, 0)?; + let match_end = opt_bool(a, 4, false)?; + if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) { + return Err(CellError::Value); + } + let mut m = find_matches(&s, &delims, mode == 1); + if match_end { + if inst > 0 { + if m.last().is_none_or(|x| x.0 != s.len()) { + m.push((s.len(), s.len())); + } + } else if m.first().is_none_or(|x| x.0 != 0) { + m.insert(0, (0, 0)); + } + } + let k = inst.unsigned_abs() as usize; + let hit = if inst > 0 { m.get(k - 1) } else { m.len().checked_sub(k).and_then(|i| m.get(i)) }; + match hit { + Some(&(st, en)) => text_val(if before { s[..st].iter().collect::() } else { s[en..].iter().collect::() }), + None => match a.get(5) { + Some(x) => Ok(scalar(x)), + None => Err(CellError::NA), + }, + } +} + +fn textbefore(a: &[Arg], _c: &mut dyn Ctx) -> R { + text_before_after(a, true) +} + +fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R { + text_before_after(a, false) +} + +fn split_by(s: &[char], delims: &[Vec], insensitive: bool) -> Vec> { + if delims.iter().all(|d| d.is_empty()) { + return vec![s.to_vec()]; + } + let mut out = Vec::new(); + let mut last = 0; + for (st, en) in find_matches(s, delims, insensitive) { + out.push(s[last..st].to_vec()); + last = en; + } + out.push(s[last..].to_vec()); + out +} + +fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = chars(&text(a, 0)?); + let col_d = if has(a, 1) { delimiters(&arg(a, 1)?.value)? } else { vec![] }; + let row_d = if has(a, 2) { delimiters(&arg(a, 2)?.value)? } else { vec![] }; + if col_d.iter().all(|d| d.is_empty()) && row_d.iter().all(|d| d.is_empty()) { + return Err(CellError::Value); + } + let ignore = opt_bool(a, 3, false)?; + let mode = opt_int(a, 4, 0)?; + if !(0..=1).contains(&mode) { + return Err(CellError::Value); + } + let pad = if has(a, 5) { scalar(arg(a, 5)?) } else { Value::Error(CellError::NA) }; + let mut rows: Vec> = Vec::new(); + for r in split_by(&s, &row_d, mode == 1) { + if ignore && r.is_empty() { + continue; + } + let cells: Vec = + split_by(&r, &col_d, mode == 1).into_iter().filter(|c| !(ignore && c.is_empty())).map(|c| c.into_iter().collect()).collect(); + if ignore && cells.is_empty() { + continue; + } + rows.push(cells); + } + if rows.is_empty() { + return Err(CellError::Calc); + } + let ncols = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); + let nrows = rows.len(); + let mut data = Vec::with_capacity(nrows * ncols); + for r in rows { + let n = r.len(); + data.extend(r.into_iter().map(Value::from)); + data.extend(std::iter::repeat_n(pad.clone(), ncols - n)); + } + array_val(nrows, ncols, data) +} + +fn value_text(v: &Value, strict: bool) -> String { + match v { + Value::Text(t) if strict => format!("\"{}\"", t.replace('"', "\"\"")), + Value::Text(t) => t.to_string(), + Value::Error(e) => e.as_str().to_string(), + Value::Number(n) => number_to_text(*n), + other => other.to_text().unwrap_or_default(), + } +} + +fn format_arg(a: &[Arg], i: usize) -> R { + match opt_int(a, i, 0)? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(CellError::Value), + } +} + +fn valuetotext(a: &[Arg], _c: &mut dyn Ctx) -> R { + let strict = format_arg(a, 1)?; + check_len(value_text(&scalar(arg(a, 0)?), strict)) +} + +fn arraytotext(a: &[Arg], _c: &mut dyn Ctx) -> R { + let strict = format_arg(a, 1)?; + let arr = as_array(&arg(a, 0)?.value); + let mut out = String::new(); + if strict { + out.push('{'); + } + for r in 0..arr.rows { + for c in 0..arr.cols { + if r > 0 && c == 0 { + out.push_str(if strict { ";" } else { ", " }); + } else if c > 0 { + out.push_str(if strict { "," } else { ", " }); + } + push_capped(&mut out, &value_text(arr.get(r, c).unwrap_or(&Value::Empty), strict))?; + } + } + if strict { + out.push('}'); + } + check_len(out) +} + +fn build_regex(pattern: &str, insensitive: bool) -> R { + if pattern.len() > MAX_TEXT { + return Err(CellError::Value); + } + regex::RegexBuilder::new(pattern) + .case_insensitive(insensitive) + .size_limit(1 << 22) + .dfa_size_limit(1 << 22) + .nest_limit(100) + .build() + .map_err(|_| CellError::Value) +} + +fn case_arg(a: &[Arg], i: usize) -> R { + match opt_int(a, i, 0)? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(CellError::Value), + } +} + +fn regextest(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let re = build_regex(&text(a, 1)?, case_arg(a, 2)?)?; + Ok(Value::Bool(re.is_match(&s))) +} + +fn regexextract(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let mode = opt_int(a, 2, 0)?; + let re = build_regex(&text(a, 1)?, case_arg(a, 3)?)?; + match mode { + 0 => re.find(&s).map(|m| Value::from(m.as_str())).ok_or(CellError::NA), + 1 => { + let all: Vec = re.find_iter(&s).take(1_000_000).map(|m| Value::from(m.as_str())).collect(); + if all.is_empty() { + return Err(CellError::NA); + } + array_val(all.len(), 1, all) + } + 2 => { + let caps = re.captures(&s).ok_or(CellError::NA)?; + if caps.len() <= 1 { + return Ok(Value::from(caps.get(0).map_or("", |m| m.as_str()))); + } + let groups: Vec = (1..caps.len()).map(|i| Value::from(caps.get(i).map_or("", |m| m.as_str()))).collect(); + array_val(1, groups.len(), groups) + } + _ => Err(CellError::Value), + } +} + +/// Converts Excel-style `$1` references to the regex crate's unambiguous `${1}` form. +fn convert_replacement(r: &str) -> String { + let mut out = String::with_capacity(r.len() + 4); + let c: Vec = r.chars().collect(); + let mut i = 0; + while i < c.len() { + let ch = c[i]; + if ch == '$' && c.get(i + 1).is_some_and(|d| d.is_ascii_digit()) { + let mut j = i + 1; + while c.get(j).is_some_and(|d| d.is_ascii_digit()) { + j += 1; + } + out.push_str("${"); + out.extend(c[i + 1..j].iter()); + out.push('}'); + i = j; + } else { + out.push(ch); + i += 1; + } + } + out +} + +fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let rep = convert_replacement(&text(a, 2)?); + let occ = opt_int(a, 3, 0)?; + let re = build_regex(&text(a, 1)?, case_arg(a, 4)?)?; + if occ == 0 { + return check_len(re.replace_all(&s, rep.as_str()).into_owned()); + } + let caps: Vec = re.captures_iter(&s).collect(); + let k = occ.unsigned_abs() as usize; + let idx = if occ > 0 { k.checked_sub(1) } else { caps.len().checked_sub(k) }; + let Some(cap) = idx.and_then(|i| caps.get(i)) else { return text_val(s) }; + let Some(m) = cap.get(0) else { return text_val(s) }; + let mut out = String::new(); + out.push_str(&s[..m.start()]); + cap.expand(&rep, &mut out); + out.push_str(&s[m.end()..]); + check_len(out) +} + +pub(crate) fn specs() -> Vec { + vec![ + f!("CONCATENATE", 1, VAR, Text, S, "CONCATENATE(text1, [text2], ...)", "Joins several text values into one.", concatenate), + f!("CONCAT", 1, VAR, Text, A, "CONCAT(text1, [text2], ...)", "Joins text from values and every cell of ranges, without a separator.", concat), + f!( + "TEXTJOIN", + 3, + VAR, + Text, + TJ, + "TEXTJOIN(delimiter, ignore_empty, text1, [text2], ...)", + "Joins text from values and ranges with a delimiter, optionally skipping empty items.", + textjoin + ), + f!("LEFT", 1, 2, Text, S, "LEFT(text, [num_chars])", "Returns the first characters of a text.", left), + f!("LEFTB", 1, 2, Text, S, "LEFTB(text, [num_bytes])", "Byte version of LEFT (same as LEFT for single-byte text).", left), + f!("RIGHT", 1, 2, Text, S, "RIGHT(text, [num_chars])", "Returns the last characters of a text.", right), + f!("RIGHTB", 1, 2, Text, S, "RIGHTB(text, [num_bytes])", "Byte version of RIGHT (same as RIGHT for single-byte text).", right), + f!("MID", 3, 3, Text, S, "MID(text, start_num, num_chars)", "Returns characters from the middle of a text.", mid), + f!("MIDB", 3, 3, Text, S, "MIDB(text, start_num, num_bytes)", "Byte version of MID (same as MID for single-byte text).", mid), + f!("LEN", 1, 1, Text, S, "LEN(text)", "Counts the characters in a text.", len), + f!("LENB", 1, 1, Text, S, "LENB(text)", "Byte version of LEN (same as LEN for single-byte text).", len), + f!("LOWER", 1, 1, Text, S, "LOWER(text)", "Converts text to lower case.", lower), + f!("UPPER", 1, 1, Text, S, "UPPER(text)", "Converts text to upper case.", upper), + f!("PROPER", 1, 1, Text, S, "PROPER(text)", "Capitalises the first letter of each word and lower-cases the rest.", proper), + f!("TRIM", 1, 1, Text, S, "TRIM(text)", "Removes leading and trailing spaces and collapses runs of spaces to one.", trim), + f!("CLEAN", 1, 1, Text, S, "CLEAN(text)", "Removes non-printable control characters from text.", clean), + f!( + "SUBSTITUTE", + 3, + 4, + Text, + S, + "SUBSTITUTE(text, old_text, new_text, [instance_num])", + "Replaces occurrences of one text with another, optionally only a given occurrence.", + substitute + ), + f!("REPLACE", 4, 4, Text, S, "REPLACE(old_text, start_num, num_chars, new_text)", "Replaces part of a text by position.", replace), + f!("REPLACEB", 4, 4, Text, S, "REPLACEB(old_text, start_num, num_bytes, new_text)", "Byte version of REPLACE.", replace), + f!("REPT", 2, 2, Text, S, "REPT(text, number_times)", "Repeats a text a given number of times.", rept), + f!("FIND", 2, 3, Text, S, "FIND(find_text, within_text, [start_num])", "Finds the position of one text in another (case-sensitive).", find), + f!("FINDB", 2, 3, Text, S, "FINDB(find_text, within_text, [start_num])", "Byte version of FIND.", find), + f!( + "SEARCH", + 2, + 3, + Text, + S, + "SEARCH(find_text, within_text, [start_num])", + "Finds the position of one text in another, ignoring case and allowing wildcards.", + search + ), + f!("SEARCHB", 2, 3, Text, S, "SEARCHB(find_text, within_text, [start_num])", "Byte version of SEARCH.", search), + f!("EXACT", 2, 2, Text, S, "EXACT(text1, text2)", "TRUE when two texts are identical, including case.", exact), + f!("CHAR", 1, 1, Text, S, "CHAR(number)", "Returns the character for a code from the Windows-1252 character set.", char_fn), + f!("CODE", 1, 1, Text, S, "CODE(text)", "Returns the Windows-1252 code of the first character of a text.", code), + f!("UNICHAR", 1, 1, Text, S, "UNICHAR(number)", "Returns the character for a Unicode code point.", unichar), + f!("UNICODE", 1, 1, Text, S, "UNICODE(text)", "Returns the Unicode code point of the first character of a text.", unicode), + f!("VALUE", 1, 1, Text, S, "VALUE(text)", "Converts text that looks like a number, date or time into a number.", value), + f!( + "NUMBERVALUE", + 1, + 3, + Text, + S, + "NUMBERVALUE(text, [decimal_separator], [group_separator])", + "Converts text to a number using the given decimal and group separators.", + numbervalue + ), + f!( + "FIXED", + 1, + 3, + Text, + S, + "FIXED(number, [decimals], [no_commas])", + "Rounds a number and formats it as text with fixed decimals and optional thousands separators.", + fixed + ), + f!("DOLLAR", 1, 2, Text, S, "DOLLAR(number, [decimals])", "Formats a number as currency text.", dollar), + f!("T", 1, 1, Text, S, "T(value)", "Returns the value if it is text, otherwise empty text.", t_fn), + f!( + "TEXTBEFORE", + 2, + 6, + Text, + TB, + "TEXTBEFORE(text, delimiter, [instance_num], [match_mode], [match_end], [if_not_found])", + "Returns the text that comes before a delimiter.", + textbefore + ), + f!( + "TEXTAFTER", + 2, + 6, + Text, + TB, + "TEXTAFTER(text, delimiter, [instance_num], [match_mode], [match_end], [if_not_found])", + "Returns the text that comes after a delimiter.", + textafter + ), + f!( + "TEXTSPLIT", + 2, + 6, + Text, + TS, + "TEXTSPLIT(text, col_delimiter, [row_delimiter], [ignore_empty], [match_mode], [pad_with])", + "Splits text into a grid of columns and rows at the given delimiters.", + textsplit + ), + f!( + "VALUETOTEXT", + 1, + 2, + Text, + S, + "VALUETOTEXT(value, [format])", + "Converts any value to text, optionally in strict (quoted) form.", + valuetotext + ), + f!( + "ARRAYTOTEXT", + 1, + 2, + Text, + ATT, + "ARRAYTOTEXT(array, [format])", + "Converts an array to text, either as a comma list or as a strict array constant.", + arraytotext + ), + f!( + "REGEXTEST", + 2, + 3, + Text, + S, + "REGEXTEST(text, pattern, [case_sensitivity])", + "TRUE when a regular expression matches somewhere in the text.", + regextest + ), + f!( + "REGEXEXTRACT", + 2, + 4, + Text, + S, + "REGEXEXTRACT(text, pattern, [return_mode], [case_sensitivity])", + "Extracts the first match, all matches or the capture groups of a regular expression.", + regexextract + ), + f!( + "REGEXREPLACE", + 3, + 5, + Text, + S, + "REGEXREPLACE(text, pattern, replacement, [occurrence], [case_sensitivity])", + "Replaces matches of a regular expression, all of them or a chosen occurrence.", + regexreplace + ), + f!("ASC", 1, 1, Text, S, "ASC(text)", "Converts full-width characters to half-width (no change for single-byte text).", identity), + f!("DBCS", 1, 1, Text, S, "DBCS(text)", "Converts half-width characters to full-width (no change in this locale).", identity), + f!("JIS", 1, 1, Text, S, "JIS(text)", "Converts half-width characters to full-width (no change in this locale).", identity), + f!("PHONETIC", 1, 1, Text, S, "PHONETIC(reference)", "Returns the phonetic (furigana) text; here the text itself.", identity), + ] +} + +#[cfg(test)] +mod tests { + use sheetcraft_core::{CellError, Value}; + + use crate::util::testutil::*; + + #[test] + fn concat_family() { + is_text(ev("CONCATENATE", vec![t("a"), n(1.5), b(true)]), "a1.5TRUE"); + is_text(ev("CONCAT", vec![av(arr(vec![vec![tv("a"), tv("b")], vec![nv(1.0), Value::Empty]])), t("!")]), "ab1!"); + is_text(ev("TEXTJOIN", vec![t("-"), b(true), av(arr(vec![vec![tv("a"), tv(""), tv("b")]])), t("c")]), "a-b-c"); + is_text(ev("TEXTJOIN", vec![t("-"), b(false), av(arr(vec![vec![tv("a"), tv(""), tv("b")]]))]), "a--b"); + is_text(ev("TEXTJOIN", vec![av(row_t(&[",", ";"])), b(true), t("a"), t("b"), t("c")]), "a,b;c"); + is_err(ev("CONCAT", vec![t("a"), e(CellError::NA)]), CellError::NA); + // Lifting: CONCATENATE over an array. + assert_eq!(rows_of(&ev("CONCATENATE", vec![av(row_t(&["a", "b"])), t("x")])), vec![vec![tv("ax"), tv("bx")]]); + } + + fn row_t(xs: &[&str]) -> Value { + arr(vec![xs.iter().map(|x| tv(x)).collect()]) + } + + #[test] + fn slicing() { + is_text(ev("LEFT", vec![t("Hello")]), "H"); + is_text(ev("LEFT", vec![t("Hello"), n(3.0)]), "Hel"); + is_text(ev("LEFT", vec![t("Hello"), n(99.0)]), "Hello"); + is_err(ev("LEFT", vec![t("Hello"), n(-1.0)]), CellError::Value); + is_text(ev("RIGHT", vec![t("Hello"), n(2.0)]), "lo"); + is_text(ev("RIGHT", vec![t("héllo"), n(4.0)]), "éllo"); + is_text(ev("MID", vec![t("Hello"), n(2.0), n(3.0)]), "ell"); + is_text(ev("MID", vec![t("Hello"), n(10.0), n(3.0)]), ""); + is_err(ev("MID", vec![t("Hello"), n(0.0), n(3.0)]), CellError::Value); + close(ev("LEN", vec![t("héllo")]), 5.0); + close(ev("LEN", vec![n(123.45)]), 6.0); + close(ev("LEN", vec![empty()]), 0.0); + is_text(ev("LEFT", vec![n(12345.0), n(2.0)]), "12"); + } + + #[test] + fn case_and_cleanup() { + is_text(ev("UPPER", vec![t("abc")]), "ABC"); + is_text(ev("LOWER", vec![t("ABC")]), "abc"); + is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title"); + is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S"); + is_text(ev("TRIM", vec![t(" a b c ")]), "a b c"); + is_text(ev("CLEAN", vec![t("a\u{7}b\nc")]), "abc"); + } + + #[test] + fn substitute_replace_rept() { + is_text(ev("SUBSTITUTE", vec![t("a-b-c"), t("-"), t("+")]), "a+b+c"); + is_text(ev("SUBSTITUTE", vec![t("a-b-c"), t("-"), t("+"), n(2.0)]), "a-b+c"); + is_text(ev("SUBSTITUTE", vec![t("a-b-c"), t("-"), t("+"), n(5.0)]), "a-b-c"); + is_err(ev("SUBSTITUTE", vec![t("a"), t("a"), t("b"), n(0.0)]), CellError::Value); + is_text(ev("SUBSTITUTE", vec![t("abc"), t(""), t("x")]), "abc"); + is_text(ev("REPLACE", vec![t("abcdef"), n(2.0), n(3.0), t("XY")]), "aXYef"); + is_text(ev("REPLACE", vec![t("abc"), n(10.0), n(1.0), t("Z")]), "abcZ"); + is_text(ev("REPT", vec![t("ab"), n(3.0)]), "ababab"); + is_text(ev("REPT", vec![t("ab"), n(0.0)]), ""); + is_err(ev("REPT", vec![t("ab"), n(-1.0)]), CellError::Value); + is_err(ev("REPT", vec![t("ab"), n(20000.0)]), CellError::Value); + is_err(ev("REPT", vec![t("ab"), n(1e300)]), CellError::Value); + } + + #[test] + fn find_search() { + close(ev("FIND", vec![t("b"), t("abcb")]), 2.0); + close(ev("FIND", vec![t("b"), t("abcb"), n(3.0)]), 4.0); + is_err(ev("FIND", vec![t("B"), t("abc")]), CellError::Value); + close(ev("FIND", vec![t(""), t("abc")]), 1.0); + is_err(ev("FIND", vec![t("a"), t("abc"), n(0.0)]), CellError::Value); + close(ev("SEARCH", vec![t("B"), t("abc")]), 2.0); + close(ev("SEARCH", vec![t("c*"), t("abcd")]), 3.0); + close(ev("SEARCH", vec![t("?d"), t("abcd")]), 3.0); + close(ev("SEARCH", vec![t("~*"), t("a*b")]), 2.0); + is_err(ev("SEARCH", vec![t("z"), t("abc")]), CellError::Value); + is_err(ev("SEARCH", vec![t("a"), t("abc"), n(5.0)]), CellError::Value); + assert_eq!(ev("EXACT", vec![t("a"), t("A")]), Value::Bool(false)); + assert_eq!(ev("EXACT", vec![t("a"), t("a")]), Value::Bool(true)); + } + + #[test] + fn codes() { + is_text(ev("CHAR", vec![n(65.0)]), "A"); + is_text(ev("CHAR", vec![n(128.0)]), "€"); + is_err(ev("CHAR", vec![n(0.0)]), CellError::Value); + is_err(ev("CHAR", vec![n(256.0)]), CellError::Value); + close(ev("CODE", vec![t("A")]), 65.0); + close(ev("CODE", vec![t("€")]), 128.0); + is_err(ev("CODE", vec![t("")]), CellError::Value); + is_text(ev("UNICHAR", vec![n(9731.0)]), "☃"); + close(ev("UNICODE", vec![t("☃")]), 9731.0); + is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value); + is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA); + } + + #[test] + fn numbers_from_text() { + close(ev("VALUE", vec![t("1,234.5")]), 1234.5); + close(ev("VALUE", vec![t("50%")]), 0.5); + close(ev("VALUE", vec![t("$10")]), 10.0); + close(ev("VALUE", vec![t("12:00")]), 0.5); + close(ev("VALUE", vec![t("2020-01-01")]), 43831.0); + is_err(ev("VALUE", vec![t("abc")]), CellError::Value); + close(ev("NUMBERVALUE", vec![t("2.500,27"), t(","), t(".")]), 2500.27); + close(ev("NUMBERVALUE", vec![t("3.5%")]), 0.035); + close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0); + close(ev("NUMBERVALUE", vec![t("")]), 0.0); + is_err(ev("NUMBERVALUE", vec![t("1.2.3")]), CellError::Value); + is_text(ev("FIXED", vec![n(1234.567), n(1.0)]), "1,234.6"); + is_text(ev("FIXED", vec![n(1234.567), n(-1.0)]), "1,230"); + is_text(ev("FIXED", vec![n(-1234.567), n(1.0), b(true)]), "-1234.6"); + is_text(ev("FIXED", vec![n(44.332)]), "44.33"); + is_text(ev("FIXED", vec![n(2.675), n(2.0)]), "2.68"); + is_text(ev("DOLLAR", vec![n(1234.567)]), "$1,234.57"); + is_text(ev("DOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)"); + is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1"); + is_text(ev("T", vec![t("x")]), "x"); + is_text(ev("T", vec![n(1.0)]), ""); + } + + #[test] + fn before_after() { + is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red"); + is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" "), n(2.0)]), "Red riding"); + is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" "), n(-1.0)]), "Red riding"); + is_text(ev("TEXTAFTER", vec![t("Red riding hood"), t(" ")]), "riding hood"); + is_text(ev("TEXTAFTER", vec![t("Red riding hood"), t(" "), n(-1.0)]), "hood"); + is_err(ev("TEXTAFTER", vec![t("abc"), t("x")]), CellError::NA); + is_text(ev("TEXTAFTER", vec![t("abc"), t("x"), n(1.0), n(0.0), n(0.0), t("none")]), "none"); + is_text(ev("TEXTAFTER", vec![t("aXb"), t("x"), n(1.0), n(1.0)]), "b"); + is_err(ev("TEXTAFTER", vec![t("aXb"), t("x")]), CellError::NA); + is_text(ev("TEXTBEFORE", vec![t("abc"), t("x"), n(1.0), n(0.0), n(1.0)]), "abc"); + is_text(ev("TEXTAFTER", vec![t("a-b+c"), av(row_t(&["+", "-"]))]), "b+c"); + is_err(ev("TEXTBEFORE", vec![t("abc"), t("b"), n(0.0)]), CellError::Value); + is_text(ev("TEXTBEFORE", vec![t("abc"), t("")]), ""); + is_text(ev("TEXTAFTER", vec![t("abc"), t("")]), "abc"); + } + + #[test] + fn split() { + let v = ev("TEXTSPLIT", vec![t("a,b;c,d"), t(","), t(";")]); + assert_eq!(rows_of(&v), vec![vec![tv("a"), tv("b")], vec![tv("c"), tv("d")]]); + let v = ev("TEXTSPLIT", vec![t("a,b;c"), t(","), t(";")]); + assert_eq!(rows_of(&v), vec![vec![tv("a"), tv("b")], vec![tv("c"), Value::Error(CellError::NA)]]); + let v = ev("TEXTSPLIT", vec![t("a,,b"), t(","), empty(), b(true)]); + assert_eq!(rows_of(&v), vec![vec![tv("a"), tv("b")]]); + let v = ev("TEXTSPLIT", vec![t("a,,b"), t(",")]); + assert_eq!(rows_of(&v), vec![vec![tv("a"), tv(""), tv("b")]]); + let v = ev("TEXTSPLIT", vec![t("a b-c"), av(row_t(&[" ", "-"]))]); + assert_eq!(rows_of(&v), vec![vec![tv("a"), tv("b"), tv("c")]]); + let v = ev("TEXTSPLIT", vec![t("1;2"), empty(), t(";")]); + assert_eq!(rows_of(&v), vec![vec![tv("1")], vec![tv("2")]]); + let v = ev("TEXTSPLIT", vec![t("aXbxc"), t("x"), empty(), b(false), n(1.0)]); + assert_eq!(rows_of(&v), vec![vec![tv("a"), tv("b"), tv("c")]]); + let v = ev("TEXTSPLIT", vec![t("a,b;c"), t(","), t(";"), b(false), n(0.0), t("-")]); + assert_eq!(rows_of(&v)[1], vec![tv("c"), tv("-")]); + } + + #[test] + fn to_text() { + is_text(ev("VALUETOTEXT", vec![t("a\"b")]), "a\"b"); + is_text(ev("VALUETOTEXT", vec![t("ab"), n(1.0)]), "\"ab\""); + is_text(ev("VALUETOTEXT", vec![n(1.5), n(1.0)]), "1.5"); + is_text(ev("VALUETOTEXT", vec![e(CellError::Div0)]), "#DIV/0!"); + is_err(ev("VALUETOTEXT", vec![n(1.0), n(2.0)]), CellError::Value); + let a = av(arr(vec![vec![nv(1.0), tv("a")], vec![Value::Bool(true), Value::Error(CellError::NA)]])); + is_text(ev("ARRAYTOTEXT", vec![a.clone()]), "1, a, TRUE, #N/A"); + is_text(ev("ARRAYTOTEXT", vec![a, n(1.0)]), "{1,\"a\";TRUE,#N/A}"); + } + + #[test] + fn regex() { + assert_eq!(ev("REGEXTEST", vec![t("abc123"), t("[0-9]+")]), Value::Bool(true)); + assert_eq!(ev("REGEXTEST", vec![t("ABC"), t("abc")]), Value::Bool(false)); + assert_eq!(ev("REGEXTEST", vec![t("ABC"), t("abc"), n(1.0)]), Value::Bool(true)); + is_err(ev("REGEXTEST", vec![t("a"), t("(")]), CellError::Value); + is_text(ev("REGEXEXTRACT", vec![t("tel 555-1234 or 555-9876"), t("[0-9]{3}-[0-9]{4}")]), "555-1234"); + let v = ev("REGEXEXTRACT", vec![t("tel 555-1234 or 555-9876"), t("[0-9]{3}-[0-9]{4}"), n(1.0)]); + assert_eq!(rows_of(&v), vec![vec![tv("555-1234")], vec![tv("555-9876")]]); + let v = ev("REGEXEXTRACT", vec![t("John Smith"), t("(\\w+) (\\w+)"), n(2.0)]); + assert_eq!(rows_of(&v), vec![vec![tv("John"), tv("Smith")]]); + is_err(ev("REGEXEXTRACT", vec![t("abc"), t("z")]), CellError::NA); + is_text(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#")]), "a#b#c"); + is_text(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#"), n(2.0)]), "a1b#c"); + is_text(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#"), n(-2.0)]), "a#b22c"); + is_text(ev("REGEXREPLACE", vec![t("John Smith"), t("(\\w+) (\\w+)"), t("$2, $1")]), "Smith, John"); + is_text(ev("REGEXREPLACE", vec![t("ab"), t("(a)"), t("$1x")]), "axb"); + } +} diff --git a/crates/functions/src/util.rs b/crates/functions/src/util.rs new file mode 100644 index 00000000..bb2d31bc --- /dev/null +++ b/crates/functions/src/util.rs @@ -0,0 +1,430 @@ +//! Shared argument coercion and collection helpers. + +use std::borrow::Cow; +use std::sync::Arc; + +use sheetcraft_core::{Array, CellError, Value}; + +use crate::Arg; + +pub(crate) type R = Result; + +/// Largest array a function may build. +pub(crate) const MAX_CELLS: usize = 10_000_000; +/// Longest text a cell can hold. +pub(crate) const MAX_TEXT: usize = 32_767; + +/// Scalar-position lifting tables. +pub(crate) const S: &[bool] = &[true]; +/// No lifting (all positions take arrays / ranges). +pub(crate) const A: &[bool] = &[false]; + +/// Turns an implementation result into a cell value: errors become error values, non-finite +/// numbers become `#NUM!`, text longer than 32,767 characters becomes `#VALUE!`. +pub(crate) fn finish(r: R) -> Value { + match r { + Ok(Value::Number(n)) => Value::number(n), + Ok(Value::Text(t)) if t.len() > MAX_TEXT && t.chars().count() > MAX_TEXT => Value::Error(CellError::Value), + Ok(v) => v, + Err(e) => Value::Error(e), + } +} + +/// A finite number or `#NUM!`. +pub(crate) fn num_val(n: f64) -> R { + if n.is_finite() { Ok(Value::Number(if n == 0.0 { 0.0 } else { n })) } else { Err(CellError::Num) } +} + +pub(crate) fn text_val(s: impl Into>) -> R { + Ok(Value::Text(s.into())) +} + +/// Scalar value of an argument (top-left element for arrays). +pub(crate) fn scalar(a: &Arg) -> Value { + a.value.scalar() +} + +/// Argument `i`, or `#VALUE!` when it is missing. +pub(crate) fn arg(args: &[Arg], i: usize) -> R<&Arg> { + args.get(i).ok_or(CellError::Value) +} + +/// Whether argument `i` was supplied (present and not an empty literal like `F(1,)`). +pub(crate) fn has(args: &[Arg], i: usize) -> bool { + matches!(args.get(i), Some(a) if !(matches!(a.value, Value::Empty) && !a.from_ref)) +} + +/// Number coercion for a scalar parameter: empty = 0, TRUE = 1, numeric text parses. +pub(crate) fn num(args: &[Arg], i: usize) -> R { + let a = arg(args, i)?; + scalar(a).to_number() +} + +/// Optional number: `default` when missing, otherwise coerced (an explicit empty is 0). +pub(crate) fn opt_num(args: &[Arg], i: usize, default: f64) -> R { + match args.get(i) { + None => Ok(default), + Some(a) => scalar(a).to_number(), + } +} + +/// Integer coercion (truncates toward zero). Values beyond ±2^53 give `#NUM!`. +pub(crate) fn int(args: &[Arg], i: usize) -> R { + to_int(num(args, i)?) +} + +pub(crate) fn opt_int(args: &[Arg], i: usize, default: i64) -> R { + match args.get(i) { + None => Ok(default), + Some(_) => int(args, i), + } +} + +pub(crate) fn to_int(n: f64) -> R { + if !n.is_finite() || n.abs() > 9.007_199_254_740_992e15 { + return Err(CellError::Num); + } + Ok(n.trunc() as i64) +} + +/// Text coercion for a scalar parameter. +pub(crate) fn text(args: &[Arg], i: usize) -> R { + let a = arg(args, i)?; + scalar(a).to_text() +} + +/// Boolean coercion for a scalar parameter. +pub(crate) fn boolean(args: &[Arg], i: usize) -> R { + let a = arg(args, i)?; + scalar(a).to_bool() +} + +pub(crate) fn opt_bool(args: &[Arg], i: usize, default: bool) -> R { + match args.get(i) { + None => Ok(default), + Some(a) => scalar(a).to_bool(), + } +} + +/// The value viewed as an array (scalars become 1×1). +pub(crate) fn as_array(v: &Value) -> Cow<'_, Array> { + match v { + Value::Array(a) => Cow::Borrowed(a.as_ref()), + other => Cow::Owned(Array::scalar(other.clone())), + } +} + +/// Wraps an array result. Rejects empty and oversized arrays. +pub(crate) fn array_val(rows: usize, cols: usize, data: Vec) -> R { + if rows.checked_mul(cols).is_none_or(|n| n > MAX_CELLS) { + return Err(CellError::Num); + } + Array::new(rows, cols, data).map(Value::from).ok_or(CellError::Calc) +} + +/// Checks that a requested result size is acceptable. +pub(crate) fn check_size(rows: f64, cols: f64) -> R<(usize, usize)> { + if rows.is_nan() || cols.is_nan() || rows < 1.0 || cols < 1.0 || !rows.is_finite() || !cols.is_finite() { + return Err(CellError::Value); + } + let (r, c) = (rows as usize, cols as usize); + if r > 1_048_576 || c > 16_384 || r.saturating_mul(c) > MAX_CELLS { + return Err(CellError::Num); + } + Ok((r, c)) +} + +/// Every value of every argument, flattened row-major (scalars as themselves). +pub(crate) fn flatten(args: &[Arg]) -> Vec { + let mut out = Vec::new(); + for a in args { + match &a.value { + Value::Array(arr) => out.extend(arr.data.iter().cloned()), + v => out.push(v.clone()), + } + } + out +} + +/// Numbers for aggregate functions (SUM, AVERAGE, MIN, STDEV…): inside references and arrays +/// only numbers count (text, booleans and blanks are skipped); direct scalar arguments are +/// coerced ("3" → 3, TRUE → 1, other text → `#VALUE!`). Errors propagate. +pub(crate) fn numbers(args: &[Arg]) -> R> { + let mut out = Vec::new(); + for a in args { + match &a.value { + Value::Array(arr) => { + for v in arr.iter() { + match v { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + } + } + Value::Error(e) => return Err(*e), + Value::Number(n) => out.push(*n), + _ if a.from_ref => {} + Value::Empty => {} + v => out.push(v.to_number()?), + } + } + Ok(out) +} + +/// Numbers for the "A" aggregates (AVERAGEA, MAXA, STDEVA…): inside references and arrays text +/// counts as 0 and booleans as 1/0, blanks are skipped; direct arguments coerce as in +/// [`numbers`]. +pub(crate) fn numbers_a(args: &[Arg]) -> R> { + let mut out = Vec::new(); + for a in args { + match &a.value { + Value::Array(arr) => { + for v in arr.iter() { + match v { + Value::Number(n) => out.push(*n), + Value::Bool(b) => out.push(if *b { 1.0 } else { 0.0 }), + Value::Text(_) => out.push(0.0), + Value::Error(e) => return Err(*e), + _ => {} + } + } + } + Value::Error(e) => return Err(*e), + Value::Empty => {} + Value::Text(_) if a.from_ref => out.push(0.0), + v => out.push(v.to_number()?), + } + } + Ok(out) +} + +/// Numbers of a single array-like argument where only numbers count (text, booleans, blanks +/// skipped), errors propagate. Used for data arrays of statistical functions. +pub(crate) fn array_numbers(v: &Value) -> R> { + let mut out = Vec::new(); + match v { + Value::Array(arr) => { + for x in arr.iter() { + match x { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + } + } + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + Value::Empty => {} + other => out.push(other.to_number()?), + } + Ok(out) +} + +/// Rounds half away from zero at `digits` decimals using the 15-significant-digit decimal +/// representation of `x`, so ROUND(2.675, 2) = 2.68 like Excel. +pub(crate) fn round_half_away(x: f64, digits: i32) -> f64 { + if !x.is_finite() || x == 0.0 { + return x; + } + let digits = digits.clamp(-400, 400) as i64; + let s = format!("{:.14e}", x.abs()); + let Some((mant, exp)) = s.split_once('e') else { return x }; + let Ok(e) = exp.parse::() else { return x }; + let mut d: Vec = mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); + // value = 0.d1d2…d15 × 10^(e+1); keep the first `keep` digits. + let keep = e + 1 + digits; + if keep >= d.len() as i64 { + return x; + } + if keep < 0 { + return 0.0; + } + let keep = keep as usize; + let round_up = d.get(keep).is_some_and(|&v| v >= 5); + d.truncate(keep); + let mut exp10 = e + 1; + if round_up { + let mut i = keep; + loop { + if i == 0 { + d.insert(0, 1); + exp10 += 1; + break; + } + i -= 1; + if let Some(v) = d.get_mut(i) { + if *v == 9 { + *v = 0; + } else { + *v += 1; + break; + } + } + } + } + if d.is_empty() { + return 0.0; + } + let digits_str: String = d.iter().map(|v| char::from(b'0' + v)).collect(); + let text = format!("0.{digits_str}e{exp10}"); + let r: f64 = text.parse().unwrap_or(x); + if x < 0.0 { -r } else { r } +} + +/// `x` rounded to `sig` significant decimal digits (via decimal formatting). +pub(crate) fn to_sig_digits(x: f64, sig: usize) -> f64 { + if !x.is_finite() || x == 0.0 { + return x; + } + let s = format!("{:.*e}", sig.saturating_sub(1).min(30), x); + s.parse().unwrap_or(x) +} + +#[cfg(test)] +pub(crate) mod testutil { + use sheetcraft_core::{Array, CellError, DateSystem, Value}; + + use crate::{Arg, Ctx}; + + /// Fixed clock (2026-10-07 12:00) and a deterministic random generator. + pub struct TestCtx { + pub state: u64, + pub sys: DateSystem, + } + impl Default for TestCtx { + fn default() -> Self { + TestCtx { state: 0x2545_F491_4F6C_DD1D, sys: DateSystem::D1900 } + } + } + impl Ctx for TestCtx { + fn date_system(&self) -> DateSystem { + self.sys + } + fn now_serial(&self) -> f64 { + 46302.5 + } + fn random(&mut self) -> f64 { + self.state ^= self.state << 13; + self.state ^= self.state >> 7; + self.state ^= self.state << 17; + (self.state >> 11) as f64 / (1u64 << 53) as f64 + } + } + + /// Calls a registered function by name with literal arguments. + pub fn ev(name: &str, args: Vec) -> Value { + let spec = crate::lookup(name).unwrap_or_else(|| panic!("function {name} not registered")); + crate::call(spec, &args, &mut TestCtx::default()) + } + /// Literal number argument. + pub fn n(x: f64) -> Arg { + Arg::val(x) + } + /// Literal text argument. + pub fn t(s: &str) -> Arg { + Arg::val(s) + } + /// Literal boolean argument. + pub fn b(x: bool) -> Arg { + Arg::val(x) + } + /// Literal error argument. + pub fn e(x: CellError) -> Arg { + Arg::val(x) + } + /// Empty literal argument (as in `F(1,)`). + pub fn empty() -> Arg { + Arg::val(Value::Empty) + } + /// Array value from rows of values. + pub fn arr(rows: Vec>) -> Value { + let r = rows.len(); + let c = rows.first().map_or(0, |x| x.len()); + Value::from(Array::new(r, c, rows.into_iter().flatten().collect()).expect("rectangular")) + } + /// Column of numbers as an array value. + pub fn col(xs: &[f64]) -> Value { + arr(xs.iter().map(|x| vec![Value::Number(*x)]).collect()) + } + /// Row of numbers as an array value. + pub fn row(xs: &[f64]) -> Value { + arr(vec![xs.iter().map(|x| Value::Number(*x)).collect()]) + } + /// Range argument (from a reference). + pub fn rf(v: Value) -> Arg { + Arg::reference(v) + } + /// Array-constant argument. + pub fn av(v: Value) -> Arg { + Arg::val(v) + } + pub fn tv(s: &str) -> Value { + Value::from(s) + } + pub fn nv(x: f64) -> Value { + Value::Number(x) + } + /// Asserts a numeric result within 1e-9 relative (or absolute near zero) tolerance. + #[track_caller] + pub fn close(v: Value, expected: f64) { + match v { + Value::Number(x) => { + let tol = 1e-9 * expected.abs().max(1.0); + assert!((x - expected).abs() <= tol, "got {x}, expected {expected}"); + } + other => panic!("expected number {expected}, got {other:?}"), + } + } + /// Like [`close`] with a custom relative tolerance. + #[track_caller] + pub fn close_tol(v: Value, expected: f64, tol: f64) { + match v { + Value::Number(x) => { + let t = tol * expected.abs().max(1.0); + assert!((x - expected).abs() <= t, "got {x}, expected {expected}"); + } + other => panic!("expected number {expected}, got {other:?}"), + } + } + #[track_caller] + pub fn is_err(v: Value, err: CellError) { + assert_eq!(v, Value::Error(err)); + } + #[track_caller] + pub fn is_text(v: Value, s: &str) { + assert_eq!(v, Value::from(s)); + } + /// Array result as rows of values. + #[track_caller] + pub fn rows_of(v: &Value) -> Vec> { + match v { + Value::Array(a) => (0..a.rows).map(|r| (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect()).collect(), + other => vec![vec![other.clone()]], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rounding() { + assert_eq!(round_half_away(2.675, 2), 2.68); + assert_eq!(round_half_away(-2.5, 0), -3.0); + assert_eq!(round_half_away(1234.5678, -2), 1200.0); + assert_eq!(round_half_away(0.285, 2), 0.29); + assert_eq!(round_half_away(1.005, 2), 1.01); + } + + #[test] + fn aggregate_numbers() { + let arr = Value::from(Array::new(1, 4, vec![Value::Number(1.0), Value::from("2"), Value::Bool(true), Value::Empty]).unwrap()); + assert_eq!(numbers(&[Arg::reference(arr.clone())]).unwrap(), vec![1.0]); + assert_eq!(numbers_a(&[Arg::reference(arr)]).unwrap(), vec![1.0, 0.0, 1.0]); + assert_eq!(numbers(&[Arg::val("3"), Arg::val(true)]).unwrap(), vec![3.0, 1.0]); + assert_eq!(numbers(&[Arg::val("x")]), Err(CellError::Value)); + assert_eq!(numbers(&[Arg::reference("x")]).unwrap(), Vec::::new()); + } +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml new file mode 100644 index 00000000..4138705d --- /dev/null +++ b/crates/model/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "sheetcraft-model" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft document model: workbooks, sheets, cells, styles, tables, charts" + +[lints] +workspace = true + +[dependencies] +sheetcraft-core = { workspace = true } +sheetcraft-formula = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/model/src/b64.rs b/crates/model/src/b64.rs new file mode 100644 index 00000000..6354952f --- /dev/null +++ b/crates/model/src/b64.rs @@ -0,0 +1,61 @@ +//! Minimal base64 for serde (image bytes in JSON). + +use serde::{Deserialize, Deserializer, Serializer}; + +const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; + for i in 0..4 { + if i <= chunk.len() { + out.push(A[((n >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +pub fn decode(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let mut buf = 0u32; + let mut bits = 0; + for c in s.bytes() { + if c == b'=' || c.is_ascii_whitespace() { + continue; + } + let v = A.iter().position(|&x| x == c)? as u32; + buf = (buf << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + Some(out) +} + +pub fn serialize(v: &[u8], s: S) -> Result { + s.serialize_str(&encode(v)) +} + +pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let s = String::deserialize(d)?; + decode(&s).ok_or_else(|| serde::de::Error::custom("invalid base64")) +} + +#[cfg(test)] +mod tests { + #[test] + fn roundtrip() { + for data in [&b""[..], b"a", b"ab", b"abc", b"hello world", &[0, 255, 128, 7]] { + assert_eq!(super::decode(&super::encode(data)).unwrap(), data); + } + assert_eq!(super::encode(b"Man"), "TWFu"); + } +} diff --git a/crates/model/src/cell.rs b/crates/model/src/cell.rs new file mode 100644 index 00000000..191f9eaa --- /dev/null +++ b/crates/model/src/cell.rs @@ -0,0 +1,74 @@ +//! Cells. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use sheetcraft_core::{RangeRef, Value}; +use sheetcraft_formula::Expr; + +use crate::style::StyleId; + +/// A parsed formula. `text` is the canonical formula text without the leading `=`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Formula { + pub text: String, + #[serde(skip)] + pub expr: Option, + /// Legacy array formula (Ctrl+Shift+Enter) covering this range; the formula lives in the + /// top-left cell. + pub array: Option, +} + +impl Formula { + /// Parses `text` (with or without `=`). Unparseable formulas keep their text and evaluate to + /// `#NAME?`. + pub fn new(text: &str) -> Formula { + let body = text.strip_prefix('=').unwrap_or(text); + match sheetcraft_formula::parse(body) { + Ok(e) => Formula { text: sheetcraft_formula::print(&e), expr: Some(e), array: None }, + Err(_) => Formula { text: body.to_string(), expr: None, array: None }, + } + } + pub fn from_expr(e: Expr) -> Formula { + Formula { text: sheetcraft_formula::print(&e), expr: Some(e), array: None } + } + /// The parsed expression, re-parsing after deserialization. + pub fn expr(&self) -> Option { + self.expr.clone().or_else(|| sheetcraft_formula::parse(&self.text).ok()) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Cell { + /// Constant value, or the cached result of the formula. + pub value: Value, + pub formula: Option>, + pub style: StyleId, +} + +impl Cell { + pub fn value(v: Value) -> Cell { + Cell { value: v, formula: None, style: StyleId::DEFAULT } + } + pub fn formula(f: Formula) -> Cell { + Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT } + } + /// No value, no formula and the default style: storing it is pointless. + pub fn is_blank(&self) -> bool { + self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT + } + /// What the formula bar shows. + pub fn input_text(&self) -> String { + if let Some(f) = &self.formula { + return format!("={}", f.text); + } + match &self.value { + Value::Text(t) => { + // Text that would parse as something else is shown with its apostrophe by Excel + // only when entered that way; we show it plainly. + t.to_string() + } + v => v.display(), + } + } +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs new file mode 100644 index 00000000..b128b53e --- /dev/null +++ b/crates/model/src/features.rs @@ -0,0 +1,478 @@ +//! Sheet features beyond cells: tables, conditional formatting, data validation, comments, +//! hyperlinks, charts, images, autofilter, print settings. + +use serde::{Deserialize, Serialize}; +use sheetcraft_core::{CellRef, RangeRef}; + +use crate::style::{Color, Style}; + +// ---------------------------------------------------------------- tables + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum TotalsFn { + #[default] + None, + Average, + Count, + CountNums, + Max, + Min, + Sum, + StdDev, + Var, + Custom, +} + +impl TotalsFn { + /// SUBTOTAL function number. + pub fn subtotal_code(&self) -> Option { + Some(match self { + TotalsFn::Average => 101, + TotalsFn::Count => 103, + TotalsFn::CountNums => 102, + TotalsFn::Max => 104, + TotalsFn::Min => 105, + TotalsFn::Sum => 109, + TotalsFn::StdDev => 107, + TotalsFn::Var => 110, + _ => return None, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TableColumn { + pub name: String, + pub totals: TotalsFn, + /// Totals row label (first column) or custom formula. + pub totals_label: Option, + /// Calculated column formula. + pub formula: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Table { + pub id: u32, + pub name: String, + /// Whole table including header and totals rows. + pub range: RangeRef, + pub header_row: bool, + pub totals_row: bool, + pub columns: Vec, + pub style: String, + pub banded_rows: bool, + pub banded_cols: bool, + pub first_col: bool, + pub last_col: bool, + pub filter_button: bool, +} + +impl Table { + pub fn data_range(&self) -> Option { + let r0 = self.range.start.row + self.header_row as u32; + let r1 = self.range.end.row.checked_sub(self.totals_row as u32)?; + if r1 < r0 { + return None; + } + Some(RangeRef::new(CellRef::new(r0, self.range.start.col), CellRef::new(r1, self.range.end.col))) + } + pub fn header_range(&self) -> Option { + self.header_row.then(|| RangeRef::new(self.range.start, CellRef::new(self.range.start.row, self.range.end.col))) + } + pub fn totals_range(&self) -> Option { + self.totals_row.then(|| RangeRef::new(CellRef::new(self.range.end.row, self.range.start.col), self.range.end)) + } + pub fn column_index(&self, name: &str) -> Option { + self.columns.iter().position(|c| c.name.eq_ignore_ascii_case(name)) + } +} + +// ---------------------------------------------------------------- conditional formatting + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CfOperator { + Between, + NotBetween, + Equal, + NotEqual, + Greater, + Less, + GreaterOrEqual, + LessOrEqual, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum CfValueKind { + Min, + Max, + Number(f64), + Percent(f64), + Percentile(f64), + Formula(String), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum CfRule { + /// Cell value compared with one or two formulas. + CellIs { op: CfOperator, a: String, b: Option, style: Box