Compare commits

...

249 Commits

Author SHA1 Message Date
Nicolás Hatcher
a00ecfdade FIX: Uses lookup tables and lngamma for large numbers
Fixes #574
2025-11-25 18:26:14 +01:00
Nicolás Hatcher
e61b15655a UPDATE: Adds a bunch of tests 2025-11-25 01:20:03 +01:00
Nicolás Hatcher
6822505602 UPDATE: Adds 56 functions in the Statistical section
Uses statrs for numerical functions

REFACTOR: Put statistical functions on its own module

This might seem counter-intuitive but the wasm build after this refactor
is 1528 bytes smaller :)
2025-11-25 01:20:03 +01:00
Nicolás Hatcher
67ef3bcf87 FIX: Correct number of arguments for functions 2025-11-23 21:02:59 +01:00
Daniel González-Albo
9aac285964 Merge pull request #570 from ironcalc/dani/app/localstorage-warning
update: add data-storage warnings to the app
2025-11-23 13:12:35 +01:00
Daniel
ba40c3c673 update: copy 2025-11-23 13:04:32 +01:00
Daniel
cc01556387 fix: nicos suggestions 2025-11-23 13:03:47 +01:00
Daniel
35323df20e fix: copilot's suggestions 2025-11-23 13:03:47 +01:00
Daniel
19c115b32f update: allow to edit sheet anems directly from tab buttons 2025-11-23 13:03:47 +01:00
Daniel
6b60b339d6 update: show tab menu on right click 2025-11-23 13:03:47 +01:00
Nicolás Hatcher
41c8d88b80 UPDATE: Adds the rest of the DATABASE functions 2025-11-23 10:48:23 +01:00
Daniel
73e5c305cc update: add a dismissable alert to the left drawer 2025-11-21 00:26:51 +01:00
Elsa Minsut
774b447c84 update: adds xlsx test for these functions 2025-11-20 22:13:23 +01:00
Elsa Minsut
23b7333572 docs: available status for implemented functions 2025-11-20 22:13:23 +01:00
Elsa Minsut
ef47c26c50 update: adds unit test for the reciprocal trigonometric functions 2025-11-20 22:13:23 +01:00
Elsa Minsut
5cc61b0de4 update: adds unit test for EXP and SIGN 2025-11-20 22:12:29 +01:00
Elsa Minsut
42e8d44454 docs: available status for implemented functions 2025-11-20 22:12:29 +01:00
Elsa Minsut
f840806f94 docs: adds style guide page to sidebar 2025-11-20 22:11:45 +01:00
Elsa Minsut
4a21d4b03a docs: style guide clarity fix 2025-11-20 22:11:45 +01:00
Elsa Minsut
4cf162eb82 docs: documentation guide edits for clarity, readability and consistency 2025-11-20 22:11:45 +01:00
Nicolás Hatcher
2cab93be18 UPDATE: Also use erfc (thanks copilot!) 2025-11-20 22:01:00 +01:00
Nicolás Hatcher
fd34e46689 UPDATE: Uses statrs instead of our own erf
This adds 2630 bytes to the wasm build and a dependency.
It is ok-ish

The idea is that it will help us greatly with the statistical functions
2025-11-20 22:01:00 +01:00
Nicolás Hatcher
3bb49d1e8f FIX: Minor cleanups 2025-11-20 21:44:05 +01:00
Nicolás Hatcher
1391f196b5 UPDATE: Adds name validation and exposes it in wasm
We do a trick I am not proud of. Because all of our errors are Strings,
we don't have a way to separate a name error from an index error,
for instance. What I do in prepend the error with a string that indicates
where it comes from.
2025-11-20 21:44:05 +01:00
Nicolás Hatcher
3db094c956 FIX: Select range in worksheet when the name is selected if possible 2025-11-20 21:44:05 +01:00
Nicolás Hatcher
50941cb6ef FIX: Make properties not optional 2025-11-20 21:44:05 +01:00
Daniel
150b516863 update: add a warning tooltip next to the title 2025-11-20 00:55:32 +01:00
Nicolás Hatcher
dc49afa2c3 FIX: Format numbers a tad better
I still think there is some way to go, but this is closer to Excel
2025-11-19 23:53:07 +01:00
Nicolás Hatcher
acb90fbb9d FIX: Issues with trigonometric functions
* Right branch for ACOT for negative numbers
* correct error for ACOTH
* Correct approx for COTH for x > 20
2025-11-19 23:53:07 +01:00
Nicolás Hatcher
7676efca44 FIX: Issues with SIGN and EXP
Fixes #563
2025-11-19 04:24:23 +01:00
Daniel
8e15c623dd docs: add a guide for documenting functions 2025-11-17 22:45:53 +01:00
Nicolás Hatcher
eb76d8dd23 FIX: Issues with INT
Fixes #535
2025-11-16 20:34:25 +01:00
Nicolás Hatcher
1053d00d22 FIX: Copilot's suggestions 2025-11-16 19:45:18 +01:00
Nicolás Hatcher
5ff4774c5a FIX: Cast to string now checks for dates, currencies or percentages
Fixes part of #535
2025-11-16 19:45:18 +01:00
Nicolás Hatcher
7e966baa0d FIX: Copilot's catch 2025-11-16 11:29:57 +01:00
Nicolás Hatcher
c52c05aa8e FIX: Fixes several issues with DATABASE functions
Fixes #547
2025-11-16 11:29:57 +01:00
Elsa Minsut
129959137d update: adds testing for MROUND, TRUNC, and INT (#542)
* update: available status for implemented functions

* update: adds xlsx test for MROUND, TRUNC and INT

* update: adds unit test for MROUND, TRUNC and INT
2025-11-16 11:25:28 +01:00
Elsa Minsut
4d5af45711 fix: removes failing cases from xlsx test 2025-11-16 11:22:29 +01:00
Elsa Minsut
471f32f92a update: adds unit test for ARABIC and ROMAN 2025-11-16 11:22:29 +01:00
Elsa Minsut
7b5427196d update: updates docs to show ARABIC and ROMAN as implemented functions 2025-11-16 11:22:29 +01:00
Elsa Minsut
66b7586730 update: adds xlsx test for ARABIC and ROMAN 2025-11-16 11:22:29 +01:00
Nicolás Hatcher
630f0e1baf FIX: Biome automatic "unsafe" updates 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
bc9fefcb70 FIX: Biome automatic updates 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
3d970acc34 FIX: Make biome happy 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
e0e566db76 UPDATE: Update frontend dependencies 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
e3fc1d229a FIX: Disables telemetry for storybook 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
78d1f6b4a4 UPDATE: Updates vite and dependencies 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
45ee1c35fe UPDATE: Update dependencies 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
671cfff619 UPDATE: Update React and Storybook 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
7e2fcec4a3 FIX: Biome apply "unsafe" changes 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
12342da649 FIX: Delete unused button 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
4e9d7611a8 FIX: Update biome and apply automatic fixes 2025-11-13 19:49:04 +01:00
Elsa Minsut
e0339f641b UPDATE: updates test for TIME, HOUR, MINUTE, SECOND (#461)
* UPDATE: updates test for TIME, HOUR, MINUTE, SECOND

* fix: updates test to remove failing edge cases

* fix: xlsx file rename for compatibility
2025-11-13 18:15:34 +01:00
Nicolás Hatcher
aa953e1ece UPDATE: Add some DATABASE functions
DAVERAGE
DCOUNT
DGET
DMAX
DMIN
DSUM
2025-11-12 23:18:47 +01:00
Daniel Gonzalez Albo
cbf75c059b fix: nicos suggestions 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
b2744efeb5 fix: copilot and nicos comments 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
ef6849e822 fix: copilot suggestions 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
aa4dd598b1 chore: remove old name manager 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
8b3bd7943e update: mobile support 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
a1d1b64b76 update: add empty space 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
5094a7fe4d update: in toolbar, open drawer instead of dialog 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
c283fd7b60 update: improve error handling 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
36beccd4ae style: adjustments in scope select 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
a252f9c626 fix: footer, header, translation file 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
f8bd03d92c update: add actions, allow drawer resize 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
e44a2e8c3e update: styling and layout 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
4217c1455b update: move all functionalities from dialog to drawer 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
d8b3ba0dae update: populate drawer, styling 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
95a7782f22 update: move drawer to its own component 2025-11-12 22:33:44 +01:00
Nicolás Hatcher
087211ebc3 UPDATE: WIP 2025-11-12 22:33:44 +01:00
Elsa Minsut
46d766c85c update: warning message shows function as implemented 2025-11-12 20:44:08 +01:00
Elsa Minsut
2a14ee73c4 update: replaces warning text on doc pages 2025-11-12 20:44:08 +01:00
Elsa Minsut
401c7c4289 update: sets implemented functions as available 2025-11-12 20:44:08 +01:00
Elsa Minsut
3246137545 update: adds unit test for COMBIN and COMBINA 2025-11-12 20:44:08 +01:00
Elsa Minsut
b1f45511d0 update: adds xlsx test for COMBIN and COMBINA 2025-11-12 20:44:08 +01:00
Nicolás Hatcher
4b93174261 FIX: Value of SEC at 0 was incorrect
Also fixed imported errors of trigonometrical functions

Fixes #531
2025-11-11 22:25:10 +01:00
Nicolás Hatcher
3111a74530 FIX: Propagate name correctly 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
ae3fcaf9e9 FIX: New workbooks are created in the users TZ falling back to UTC 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
dd78db3d2b FIX: NOW shows now formatted output 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
acf334074f FIX: Include misconfigured test file 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
e48810d91b FIX: Removed some console.log lines 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
18db1cf052 FIX: Two small fixes to YEARFRAC
* Takes abs value in between two dates
* Follows ODFv1.2 part 2 section 4.11.7.7
2025-11-08 22:40:18 +01:00
Elsa Minsut
ed40f79324 FIX: Skip numerical failure in windows 2025-11-08 17:56:07 +01:00
Elsa Minsut
10ee95c48f FIX: Badge type 2025-11-08 17:56:07 +01:00
Elsa Minsut
741a223f3d update: Math and Trigonometry main page links to new docs 2025-11-08 17:56:07 +01:00
Elsa Minsut
ba139d1b6c update: adds MOD and QUOTIENT doc pages 2025-11-08 17:56:07 +01:00
Elsa Minsut
e0306cb161 update: adds unit test for MOD and QUOTIENT 2025-11-08 17:56:07 +01:00
Elsa Minsut
cea1f67cd0 update: adds xlsx tests for MOD and QUOTIENT 2025-11-08 17:56:07 +01:00
Nicolás Hatcher Andrés
4a3eef5a81 FIX: TRUE/FALSE for QUOTIENT (#524)
Excel returns #VALUE! when arguments are boolean

NB: MOD is different!
2025-11-08 17:25:02 +01:00
Elsa Minsut
91299e3c0b update: fixes status for implemented functions (#520) 2025-11-08 08:54:38 +01:00
Nicolás Hatcher Andrés
1b38d79b81 FIX: Make clippy happy (#521) 2025-11-08 08:53:50 +01:00
Elsa Minsut
a2d11a42cc update: adds docs, unit tests and xlsx tests for EVEN and ODD functions (#517)
* update: adds unit test for EVEN and ODD functions

* update: adds xlsx test for EVEN and ODD functions

* update: adds EVEN and ODD doc pages

* update: Math and Trigonometry main page links to new functions

* update: changes to functions badge type in main Math and Trigonometry page
2025-11-07 04:26:01 +01:00
Elsa Minsut
480a2d1769 update: adds docs, unit tests and xlsx tests for DATEVALUE and TIMEVALUE functions (#506)
* update: adds documentation for DATEVALUE and TIMEVALUE functions

* update: adds DATEVALUE and TIMEVALUE unit tests

* update: adds DATEVALUE and TIMEVALUE xlsx tests

* update: Date and Time main page links

* update: adds testing for multiple arguments

* update: removes links to example files

* update: removes DATEVALUE and TIMEVALUE xlsx tests
2025-11-06 22:56:14 +01:00
Elsa Minsut
f30f6864e2 update: adds docs and xlsx tests for DEGREES and RADIANS functions (#507)
* update: adds DEGREES and RADIANS documentation pages

* update: adds DEGREES and RADIANS xlsx tests

* update: Math and Trigonometry main page links

* update: removes links to missing example file
2025-11-06 22:55:28 +01:00
Nicolás Hatcher Andrés
d4f69f2ec2 UPDATE: Adds missing information functions (#514)
* UPDATE: Adds missing information functions

Implements N, CELL, INFO and SHEETS

Note that INFO is implemented as N/IMPL! and CELL is not implemented
for those values that is not implemented in Excel for the web

* FIX: Copilot fixes

* FIX: Make clippy happy
2025-11-06 18:58:39 +01:00
Daniel González-Albo
3d265bba27 update: in the app, add missing favicons and use dynamic title (#508)
* update: adds multiple favicon options to the app

* update: uses the current workbook name in as page title

* update: replace favicons in assets
2025-11-05 20:54:39 +01:00
Nicolás Hatcher Andrés
68a33a5f87 UPDATE: Adds COMBIN, COMBINA and SUMSQ (#511) 2025-11-04 22:16:16 +01:00
Nicolás Hatcher Andrés
e5854ab3d7 UPDATE: Adds ARABIC and ROMAN (#509) 2025-11-03 23:44:22 +01:00
Nicolás Hatcher Andrés
7f57826371 UPDATE: Implements BASE and DECIMAL (#504) 2025-11-02 23:30:43 +01:00
Daniel González-Albo
8b7fdce278 style: widget footer improvements (#503)
* fix: add menu items to translation file

* style: tooltips, icons and paddings in footer

* style: beautify link to main site
2025-11-02 19:59:13 +01:00
Nicolás Hatcher Andrés
3e2b177ffe UPDATE: Adds GCD and LCM functions (#502)
* UPDATE: Adds GCD and LCM functions

They follow SUM and accept arrays

* FIX: Implement copilot suggestions
2025-11-02 19:50:58 +01:00
Nicolás Hatcher Andrés
efb3b66777 UPDATE: Adds time formats (#501)
* UPDATE: Adds time formats

This is the initial implementation of time formats. Simple things like:

"hh:mm:ss AM/PM"

works

* FIX: Correct padded vs unppadded time formats

Thank you copilot!
2025-11-02 13:18:26 +01:00
Nicolás Hatcher Andrés
b2d848ae2a UPDATE: Adds a bunch of mathematical functions (#496) 2025-11-01 19:32:49 +01:00
Nicolás Hatcher Andrés
c8ae835bbe UPDATE: Adds unit tests for DEGREES and RADIANS (#495) 2025-11-01 11:23:29 +01:00
Nicolás Hatcher Andrés
6ce4756d55 UPDATE: Adds DEGREES and RADIANS (#493) 2025-10-30 23:45:29 +01:00
Nicolás Hatcher Andrés
a768bc5974 Bugfix/nicolas bufixes (#491)
* UPDATE: package lock

* FIX: Add function definitions

* FIX: Small fix to get FACT working

* FIX: We only need integer FACT and FACTDOUBLE

* FIX: Make clippy happy
2025-10-30 23:24:47 +01:00
Nicolás Hatcher Andrés
7e379e24e7 UPDATE: Adds simple functions (#489)
Exp, Fact, Factdouble and sign
2025-10-30 18:28:07 +01:00
Nicolás Hatcher Andrés
f2f4992230 UPDATE: Add some missing trigonometric functions (#487)
Acot, Acoth, Cot, Coth, Csc, Csch, Sec, Sech,
2025-10-30 17:38:02 +01:00
Nicolás Hatcher Andrés
a890865eaf FIX: Quote sheet names properly (#486)
Fixes #485
2025-10-29 23:26:18 +01:00
Nicolás Hatcher Andrés
1edfb2df1c FIX: Correct order when stringify -(A1^1.22) and (-A1)^1.22 (#484)
Fixes #483
2025-10-27 19:09:31 +01:00
Nicolás Hatcher Andrés
c88bcb94ae FIX: Uses a dump randomUUID in non secure environmentes (#482)
Fixes #480
2025-10-25 17:25:29 +02:00
Nicolás Hatcher Andrés
371bec2805 FIX: Add image info (#479) 2025-10-24 22:11:39 +02:00
Elsa Minsut
92527b5e92 update: fixes to Date and Time main page (#477) 2025-10-22 12:22:24 +02:00
Nicolás Hatcher Andrés
f6b7af3555 FIX: Updates docs and minor fixes (#474) 2025-10-22 02:30:28 +02:00
Nicolás Hatcher Andrés
fad8bc7d0c UPDATE: Update release to 0.6.0 (#463)
Should we have a single version number at the root?
2025-10-19 18:32:07 +02:00
Nicolás Hatcher Andrés
bbba875da3 FIX: Cleanup CSS code (#462) 2025-10-19 17:57:26 +02:00
Daniel González-Albo
1b34db0bc3 Merge pull request #455 from ironcalc/empty-fix
FIX: Evaluate after deleting content in the user API
2025-10-19 17:25:48 +02:00
Daniel González-Albo
d9aac1d77c Merge pull request #460 from ironcalc/xfln
FIX: DAYS and ISOWEEKNUM are "XLFN" functions
2025-10-19 17:24:40 +02:00
Nicolás Hatcher Andrés
d429bd8f60 FIX: Remove transition so there is no close drawer glitch (#459) 2025-10-19 17:12:49 +02:00
Nicolás Hatcher
2dbc3f4790 FIX: DAYS and ISOWEEKNUM are "XLFN" functions 2025-10-19 17:12:12 +02:00
Daniel González-Albo
292ecafb31 Merge pull request #458 from ironcalc/name-sync
FIX: sync changes of the localstorage in the left drawer
2025-10-19 16:13:38 +02:00
Daniel González-Albo
ead4bc713c docs: update the section 'managing workbooks' with new info about the left sidebar (#457) 2025-10-19 16:11:59 +02:00
Nicolás Hatcher
a9748eafec FIX: sync changes of the localstorage in the left drawer
This is a bit of a HACK. going a bit "against" React philosophy.
2025-10-19 16:05:50 +02:00
Nicolás Hatcher Andrés
330a018202 FIX: Adds test for TIME/HOUR/MINUTE/SECOND (#456) 2025-10-19 15:38:11 +02:00
Daniel González-Albo
d9812876e2 update: show which format is active in FormatMenu (#450)
* update: show which format is active in formatmenu

* update: requested fixes
2025-10-19 12:44:58 +02:00
Nicolás Hatcher
895244ed11 FIX: Evaluate after deleting content in the user API 2025-10-19 12:41:05 +02:00
Daniel González-Albo
f2da24326b update: Add a left drawer to improve workbook management (#453)
* update: add leftbar to app

* style: a few cosmetic changes

* update: allow pinning workbooks

* style: show ellipsis button only on hover

* update: add basic responsiveness

* style: use active state when file and help menus are open

* style: increase transition time

* update: allow duplication of workbooks

* chore: standardize menus
2025-10-19 10:20:31 +02:00
Brian Hung
dd4467f95d date time functions (#425)
* merge networkdays, networkdays.intl #33

* merge time, timevalue, hour, minute, second #35

* merge datedif, datevalue #36

* merge days, days360, weekday, weeknum, workday, workday.intl, yearfrac, isoweeknum #41

* from excel helper

* fix build

* date time macros

* de-dupe weekend

* serial helper

* de-dupe now today

* weekend pattern enum

* remove unused clippy wrong self

* fix docs

* add test coverage

* fix build

* fix cursor comment

* PR coments + xlsx date time
2025-10-19 10:19:19 +02:00
Nicolás Hatcher Andrés
29989b9fd7 UPDATE: Add info in README about the Dockerfile (#452)
* UPDATE: Add info in README about the Dockerfile

* Update README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-15 22:51:18 +02:00
Nicolás Hatcher
1efc921ce6 UPDATE: First Docker images! 2025-10-15 22:19:25 +02:00
Nicolás Hatcher
ed64716f0f FIX: Refactor both dialogs to get common code 2025-10-12 14:23:12 +02:00
Nicolás Hatcher
dd29287c5a FIX: Lifts TemplateDialog to App.tsx 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
7841abe2d2 update: use a different dialog for templates only 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
49c3d1e03a chore: remove optional props 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
b709041f9d chore: move new ic icon to icons folder 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
b177a33815 fix: remove obsolete css prefixes 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
b506ccf908 update: rename function 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
eb3e92ffd8 update: make dialogs content change depending on scenario 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
0b925a4d6a update: add a second New from template action to FileMenu 2025-10-12 14:23:12 +02:00
Nicolás Hatcher
6a3e37f4c1 FIX: Integration of Welcome dialog 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
2496227344 fix: lint 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
72355a5201 update: add a compontent for the list items 2025-10-12 14:23:12 +02:00
Daniel Gonzalez Albo
81901ec717 update: add a welcome dialog to the app 2025-10-12 14:23:12 +02:00
Dmitry S
aa664a95a1 Fix example in README to reference Model correctly.
Since https://github.com/ironcalc/IronCalc/pull/27, using
`base::model::Model` complains about `model` being a private modulde,
and `base::Model` should be used instead.
2025-10-08 08:56:03 +02:00
Daniel Gonzalez Albo
c1aa743763 fix: typo in format presets 2025-10-07 20:19:57 +02:00
Daniel Gonzalez Albo
6321030ac8 style: add offset to arrow's tooltips 2025-10-07 20:19:57 +02:00
Daniel Gonzalez Albo
c2c5751ee3 style: add nicer tooltips 2025-10-07 20:19:57 +02:00
Daniel Gonzalez Albo
6c27ae1355 update: show scroll arrows on narrow displays 2025-10-07 20:19:57 +02:00
Daniel Gonzalez Albo
7bcd978998 style: tiny adjustment in dividers 2025-10-07 20:19:57 +02:00
Daniel Gonzalez Albo
3f083d9882 adjust spacing and group button groups 2025-10-07 20:19:57 +02:00
Nicolás Hatcher
8844b80c51 FIX: cargo fmt issue 2025-09-28 17:33:17 +02:00
Nicolás Hatcher
0f8f345aae UPDATE: have xlsx_2_icalc specify its output name
This is nice for deployments
2025-09-28 17:33:17 +02:00
Nicolás Hatcher
3191e12b93 FIX: Make clippy happy 2025-09-28 17:33:17 +02:00
Nicolás Hatcher
61cecb7af5 FIX: Fixes case with unicode characters
This is an ugly bug in ugly code. Pretty much technical deb in here
2025-09-28 12:46:16 +02:00
Nicolás Hatcher
fdeae2c771 UPDATE: Add templates 2025-09-28 12:46:16 +02:00
Matt Lehrer
3e9c69f122 add model.evaluate() call 2025-09-28 12:03:28 +02:00
Tom
c1c43143cc Adds information about references and corrected syntax on Column
This is a test commit
2025-09-25 19:25:32 +02:00
Tom
763b43a590 UPDATE: Added documentation for the Column Function 2025-09-25 19:25:32 +02:00
Daniel González-Albo
8dbfe07392 Merge pull request #443 from elsaminsut/mathfunctions
docs: adds ATAN2, ASINH, ACOSH, ATANH documentation pages
2025-09-24 20:26:53 +02:00
Elsa Minsut
e39bfe912a docs: improve consistency in ATAN2, ASINH, ACOSH, ATANH documentation 2025-09-24 19:36:30 +02:00
Elsa Minsut
9bbf94e033 update: Math and Trigonometry main page links 2025-09-24 19:19:16 +02:00
Elsa Minsut
0194912845 update: adds ATANH documentation page 2025-09-24 18:05:18 +02:00
Elsa Minsut
1d4d84bb57 update: adds ASINH documentation page 2025-09-24 17:46:56 +02:00
Elsa Minsut
e841c17aca update: adds ACOSH documentation page 2025-09-24 17:19:37 +02:00
Elsa Minsut
f2c43f2070 update: adds ATAN2 documentation page 2025-09-24 17:06:20 +02:00
Nicolás Hatcher
32b1f8ef4e FIX: Update documentation of some documented functions 2025-09-23 18:01:17 +02:00
Nicolás Hatcher
81e96f1401 FIX: Shift+Letter starts editing cell 2025-09-20 10:54:27 +02:00
Daniel González-Albo
aa4ecb2c89 Merge pull request #438 from elsaminsut/elsaminsut/hyperbolicfunctions
docs: adds SINH, COSH, TANH documentation pages
2025-09-15 16:28:18 +02:00
Elsa Minsut
e116d7d29f chore: retrigger CI 2025-09-15 16:15:18 +02:00
Elsa Minsut
cd75380923 update: links in general page, typo fix in ATAN 2025-09-13 16:53:05 +02:00
Elsa Minsut
79af9c6cb5 update: adds a ATAN page to documentation 2025-09-13 16:53:05 +02:00
Elsa Minsut
96fb0aaa96 update: adds a ACOS page to documentation 2025-09-13 16:53:05 +02:00
Elsa Minsut
03dfc151e2 update: adds ASIN documentation page 2025-09-13 16:53:05 +02:00
Andrew Grosser
d122f0cbd1 irconcalc code style enforcement 2025-09-13 09:35:35 +02:00
Andrew Grosser
1476e8f6da added dimensions
Signed-off-by: Andrew Grosser <dioptre@gmail.com>
2025-09-13 09:35:35 +02:00
Nicolás Hatcher
8ca73c6224 FIX: cargo fmt. Automatic fixes 2025-09-12 19:13:33 +02:00
Nicolás Hatcher
1017eef981 FIX: Cargo clippy. Manual fixes 2025-09-12 19:13:33 +02:00
Nicolás Hatcher
1981b0833a FIX: Clippy fix. Automatic fixes 2025-09-12 19:13:33 +02:00
Elsa Minsut
fb2f2a9fcf docs: updates Math and Trigonometry main page links 2025-09-08 19:38:48 +02:00
Elsa Minsut
91276b0f60 docs: adds TANH documentation page 2025-09-08 19:36:18 +02:00
Elsa Minsut
ec841f2fd9 docs: format fix in SINH documentation page 2025-09-08 19:19:46 +02:00
Elsa Minsut
8ebcb5dcb9 docs: adds COSH documentation page 2025-09-08 19:17:21 +02:00
Elsa Minsut
f7a3b95db5 update: adds SINH documentation page 2025-08-20 18:02:23 +02:00
Daniel González-Albo
911175f0d2 Merge pull request #434 from elsaminsut/elsaminsut/TAN-typo
docs: fix typo in TAN function page
2025-08-12 18:19:48 +02:00
Elsa Minsut
4d75f6b5c0 fix: typo in TAN function page 2025-08-12 18:04:23 +02:00
Nicolás Hatcher
f3f59dbda7 FIX: Make fmt happy 2025-08-09 10:43:19 +02:00
Nicolás Hatcher
f581ce5570 FIX: Update xlsx test for ROUND, ROUNDUP 2025-08-09 10:43:19 +02:00
Nicolás Hatcher
429615ae85 FIX: Fixes stringify with parentheses 2025-08-09 10:43:19 +02:00
Nicolás Hatcher
f2cb05d7bf FIX: Fixes ROUND, ROUNDUP and ROUNDDOWN behaviour 2025-08-09 10:43:19 +02:00
Nicolás Hatcher
71d6a3d66c FIX: Make copilot happy
Oh wait, that's a thing now?
2025-08-02 16:11:38 +02:00
Nicolás Hatcher
07854f1593 UPDATE: Python bindings for the user API 2025-08-02 16:11:38 +02:00
Nicolás Hatcher
faa0ff9b69 FIX: Minimal support for inlineStr
Fixes #424
2025-08-02 12:55:56 +02:00
Nicolás Hatcher
b9b3cb1628 FIX: Lint issues 2025-07-26 15:00:22 +02:00
Nicolás Hatcher
b157347e68 UPDATE: Add move/row column to the UI 2025-07-26 15:00:22 +02:00
Nicolás Hatcher
fb7886ca9e UPDATE: Add keyboard shortcuts for move column/row
Also clean up a bit of the  keyboard shortcuts mess
2025-07-26 15:00:22 +02:00
BrianHung
caf26194df feat: implement move column move row with tests 2025-07-26 10:37:03 +02:00
Brian Hung
e420f7e998 fix intermediate rows cols 2025-07-26 10:37:03 +02:00
BrianHung
d73b5ff12d call diff list in user model move row move col 2025-07-26 10:37:03 +02:00
BrianHung
d45e8fd56d add row height test case + nodejs bindings 2025-07-26 10:37:03 +02:00
BrianHung
c2777c73ac feat: implement move column move row with tests 2025-07-26 10:37:03 +02:00
Brian Hung
7dc49d5dd7 fully deprecate old single row col methods 2025-07-24 22:51:39 +02:00
Brian Hung
183d04b923 fix build 2025-07-24 22:51:39 +02:00
Brian Hung
037766c744 feat: add bulk diff types for insert/delete row/column operations 2025-07-24 22:51:39 +02:00
BrianHung
d5ccd9dbdd fix build 2025-07-24 22:51:39 +02:00
Brian Hung
3f1f2bb896 add validation 2025-07-24 22:51:39 +02:00
Brian Hung
a2181a5a48 fix empty row deletion 2025-07-24 22:51:39 +02:00
Brian Hung
b07603b728 fix deleting empty row 2025-07-24 22:51:39 +02:00
Brian Hung
fe87dc49b4 allow panic 2025-07-24 22:51:39 +02:00
Brian Hung
b4349ff5da fix diff generation and add test coverage 2025-07-24 22:51:39 +02:00
BrianHung
51f2da8663 deprecate singlular case insert delete rows columns 2025-07-24 22:51:39 +02:00
BrianHung
87cdfb2ba1 add tests for user model insert delete rows columns 2025-07-24 22:51:39 +02:00
BrianHung
d7113622e7 deduplicate single insert delete row column 2025-07-24 22:51:39 +02:00
BrianHung
2d23f5d4e4 feat: insert delete rows cols 2025-07-24 22:51:39 +02:00
Nicolás Hatcher
56abac79ca FIX: Skip cells that have already been computed 2025-07-19 09:07:11 +02:00
Nicolás Hatcher
7193c9bf1b FIX: Identify off the view port cells to the left correctly 2025-07-19 09:07:11 +02:00
Nicolás Hatcher
266c14d5d2 FIX: Apply copilot suggestions 2025-07-19 09:07:11 +02:00
Nicolás Hatcher
9852ce2504 UPDATE: Text spills now to adjacent cells if needed 2025-07-19 09:07:11 +02:00
Nicolás Hatcher
107fc99409 FIX: $isActive is a required property of FileMenuWrapper 2025-07-18 07:02:00 +02:00
Nicolás Hatcher
77bb7ebe0e FIX: Removes ununsed code 2025-07-18 07:02:00 +02:00
Daniel
f8af302413 update: change link destination 2025-07-18 07:02:00 +02:00
Daniel
c700101f35 fix: copilot suggestions 2025-07-18 07:02:00 +02:00
Daniel
5f659a2eb5 update: lint 2025-07-18 07:02:00 +02:00
Daniel
40baf16a73 update: adjustments in file menu 2025-07-18 07:02:00 +02:00
Daniel
61c71cd6f6 update: create a Help menu 2025-07-18 07:02:00 +02:00
Nicolás Hatcher
b99ddbaee2 FIX[docs]: Inlude Linux also in keyboadr shortcuts 2025-07-18 06:27:42 +02:00
Nicolás Hatcher
2428c6c89b FIX[docs]: Update dependencies 2025-07-18 06:27:42 +02:00
Nicolás Hatcher
46b1ade34a FIX: Adds sheet "block" navigation
This was a left over from the old days

Control+right arrow takes you to the next cell with text
or the end of the block

NB: Missing tests
2025-07-17 18:39:46 +02:00
Nicolás Hatcher
1eea2a8c46 FIX: apply copilot suggestion :) 2025-07-17 07:59:55 +02:00
Daniel
eb8b129431 update: in Type and Bucket buttons, use a colored div instead of a border 2025-07-17 07:59:55 +02:00
Daniel
4850b6518f docs: complete the Keyboar Shortcuts page 2025-07-17 07:59:33 +02:00
Nicolás Hatcher
77a784df86 FIX: Removes old icon references 2025-07-17 07:44:59 +02:00
Daniel González-Albo
57896f83ca Merge pull request #413 from ironcalc/update/dgac-decimal-icons 2025-07-14 00:37:43 +02:00
Daniel
cfaa373510 update: replace custom made icons with lucide's 2025-07-13 21:04:30 +02:00
Nicolás Hatcher
cc140b087d FIX: Export fronzen rows/columns properly 2025-07-13 12:44:21 +02:00
Nicolás Hatcher
42c651da29 FIX: Do not propagate on cut or double click in the editor 2025-07-13 12:41:24 +02:00
Nicolás Hatcher
2a5f001361 UPDATE: Adds LOG10 and LN for Elsa 2025-07-13 00:10:32 +02:00
Daniel
df913e73d4 fix: greys in theme palette 2025-07-08 21:33:18 +02:00
Nicolás Hatcher
198f3108ef FIX: Remove wrong content-type is sharing workbooks
This failed to share workbooks with emojis, for instance
2025-07-06 23:08:27 +02:00
Nicolás Hatcher
3a68145848 FIX: Copied csv had an extra line
Fixes #393
2025-07-04 23:30:51 +02:00
Nicolás Hatcher
5d7f4a31d6 FIX: Correct range when pasting csv tetx 2025-07-04 23:30:51 +02:00
Nicolás Hatcher
7c8cd22ad8 FIX: stop propagation on copy in the editor
Fixes #392
2025-07-04 18:18:38 +02:00
Nicolás Hatcher
84c3cf01ce FIX: Checks also that the sheet is the same
Fixes #395
2025-07-04 02:33:24 +02:00
Nicolás Hatcher
33e9894f9b FIX: Quote sheet names properly
This is a "Hot Fix". A proper fix should use the wasm module
2025-07-04 01:52:37 +02:00
Nicolás Hatcher
483cd43041 FIX: Removing styles of full columns or rows are a single diff list
Fixes #400
2025-07-04 01:43:30 +02:00
Nicolás Hatcher
b4aed93bbb FIX[workbook]: Default fill should be None
Fixes #398
2025-07-03 23:21:54 +02:00
Nicolás Hatcher
689f55effa FIX: Apply copilot hints 2025-07-03 20:40:48 +02:00
Nicolás Hatcher
b1327d83d4 UPDATE: Removes once_Cell dependency 2025-07-03 20:40:48 +02:00
Nicolás Hatcher
8f7798d088 FIX: Apply copilot suggestion 2025-07-03 20:02:33 +02:00
Nicolás Hatcher
df0012a1c4 FIX[webapp]: Allow downloading workbooks with unicode charaters 2025-07-03 20:02:33 +02:00
369 changed files with 26544 additions and 5790 deletions

View File

@@ -117,7 +117,7 @@ jobs:
MATURIN_REPOSITORY_URL: "https://test.pypi.org/legacy/" MATURIN_REPOSITORY_URL: "https://test.pypi.org/legacy/"
with: with:
command: upload command: upload
args: "--skip-existing **/*.whl" args: "--skip-existing **/*.whl **/*.tar.gz"
working-directory: bindings/python working-directory: bindings/python
publish-pypi: publish-pypi:
@@ -137,5 +137,5 @@ jobs:
MATURIN_REPOSITORY_URL: "https://upload.pypi.org/legacy/" MATURIN_REPOSITORY_URL: "https://upload.pypi.org/legacy/"
with: with:
command: upload command: upload
args: "--skip-existing **/*.whl" args: "--skip-existing **/*.whl **/*.tar.gz"
working-directory: bindings/python working-directory: bindings/python

57
Cargo.lock generated
View File

@@ -43,6 +43,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.6"
@@ -414,7 +423,7 @@ dependencies = [
[[package]] [[package]]
name = "ironcalc" name = "ironcalc"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"bitcode", "bitcode",
"chrono", "chrono",
@@ -430,25 +439,25 @@ dependencies = [
[[package]] [[package]]
name = "ironcalc_base" name = "ironcalc_base"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"bitcode", "bitcode",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"csv", "csv",
"js-sys", "js-sys",
"once_cell",
"rand", "rand",
"regex", "regex",
"regex-lite", "regex-lite",
"ryu", "ryu",
"serde", "serde",
"serde_json", "serde_json",
"statrs",
] ]
[[package]] [[package]]
name = "ironcalc_nodejs" name = "ironcalc_nodejs"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"ironcalc", "ironcalc",
"napi", "napi",
@@ -721,11 +730,10 @@ dependencies = [
[[package]] [[package]]
name = "pyo3" name = "pyo3"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57fe09249128b3173d092de9523eaa75136bf7ba85e0d69eca241c7939c933cc" checksum = "f239d656363bcee73afef85277f1b281e8ac6212a1d42aa90e55b90ed43c47a4"
dependencies = [ dependencies = [
"cfg-if",
"indoc", "indoc",
"libc", "libc",
"memoffset", "memoffset",
@@ -739,9 +747,9 @@ dependencies = [
[[package]] [[package]]
name = "pyo3-build-config" name = "pyo3-build-config"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cd3927b5a78757a0d71aa9dff669f903b1eb64b54142a9bd9f757f8fde65fd7" checksum = "755ea671a1c34044fa165247aaf6f419ca39caa6003aee791a0df2713d8f1b6d"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"target-lexicon", "target-lexicon",
@@ -749,9 +757,9 @@ dependencies = [
[[package]] [[package]]
name = "pyo3-ffi" name = "pyo3-ffi"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab6bb2102bd8f991e7749f130a70d05dd557613e39ed2deeee8e9ca0c4d548d" checksum = "fc95a2e67091e44791d4ea300ff744be5293f394f1bafd9f78c080814d35956e"
dependencies = [ dependencies = [
"libc", "libc",
"pyo3-build-config", "pyo3-build-config",
@@ -759,9 +767,9 @@ dependencies = [
[[package]] [[package]]
name = "pyo3-macros" name = "pyo3-macros"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91871864b353fd5ffcb3f91f2f703a22a9797c91b9ab497b1acac7b07ae509c7" checksum = "a179641d1b93920829a62f15e87c0ed791b6c8db2271ba0fd7c2686090510214"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"pyo3-macros-backend", "pyo3-macros-backend",
@@ -771,9 +779,9 @@ dependencies = [
[[package]] [[package]]
name = "pyo3-macros-backend" name = "pyo3-macros-backend"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43abc3b80bc20f3facd86cd3c60beed58c3e2aa26213f3cda368de39c60a27e4" checksum = "9dff85ebcaab8c441b0e3f7ae40a6963ecea8a9f5e74f647e33fcf5ec9a1e89e"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@@ -784,8 +792,9 @@ dependencies = [
[[package]] [[package]]
name = "pyroncalc" name = "pyroncalc"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"bitcode",
"ironcalc", "ironcalc",
"pyo3", "pyo3",
"serde", "serde",
@@ -966,6 +975,16 @@ version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "statrs"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e"
dependencies = [
"approx",
"num-traits",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.5.0" version = "2.5.0"
@@ -985,9 +1004,9 @@ dependencies = [
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
@@ -1076,7 +1095,7 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]] [[package]]
name = "wasm" name = "wasm"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"ironcalc_base", "ironcalc_base",
"serde", "serde",

93
Dockerfile Normal file
View File

@@ -0,0 +1,93 @@
FROM rust:latest AS builder
WORKDIR /app
COPY . .
# Tools + wasm toolchain + Node via nvm
RUN apt-get update && apt-get install -y --no-install-recommends \
bash curl ca-certificates make \
&& rustup target add wasm32-unknown-unknown \
&& cargo install wasm-pack \
&& bash -lc "curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash -" \
&& bash -lc '\
export NVM_DIR="$HOME/.nvm" && \
source "$NVM_DIR/nvm.sh" && \
nvm install 22 && nvm alias default 22 && \
nroot="$NVM_DIR/versions/node/$(nvm version default)/bin" && \
ln -sf "$nroot/node" /usr/local/bin/node && \
ln -sf "$nroot/npm" /usr/local/bin/npm && \
ln -sf "$nroot/npx" /usr/local/bin/npx \
' \
&& npm install typescript \
&& rm -rf /var/lib/apt/lists/*
# build the server
RUN cargo build --release --manifest-path webapp/app.ironcalc.com/server/Cargo.toml
# build the wasm
RUN make -C bindings/wasm
# build the widget
WORKDIR /app/webapp/IronCalc
RUN npm install && npm run build
# build the frontend app
WORKDIR /app/webapp/app.ironcalc.com/frontend
RUN npm install && npm run build
# build the xlsx_2_icalc binary (we don't need the release version here)
WORKDIR /app/xlsx
RUN cargo build
WORKDIR /app
# copy the artifacts to a dist/ directory
RUN mkdir dist
RUN mkdir dist/frontend
RUN cp -r webapp/app.ironcalc.com/frontend/dist/* dist/frontend/
RUN mkdir dist/server
RUN cp webapp/app.ironcalc.com/server/target/release/ironcalc_server dist/server/
RUN cp webapp/app.ironcalc.com/server/Rocket.toml dist/server/
RUN cp webapp/app.ironcalc.com/server/ironcalc.sqlite dist/server/
# Create ic files in docs
RUN mkdir -p dist/frontend/models
# Loop over all xlsx files in xlsx/tests/docs & templates and convert them to .ic
RUN bash -lc 'set -euo pipefail; \
mkdir -p dist/frontend/models; \
shopt -s nullglob; \
for xlsx_file in xlsx/tests/docs/*.xlsx; do \
base_name="${xlsx_file##*/}"; base_name="${base_name%.xlsx}"; \
./target/debug/xlsx_2_icalc "$xlsx_file" "dist/frontend/models/${base_name}.ic"; \
done; \
for xlsx_file in xlsx/tests/templates/*.xlsx; do \
base_name="${xlsx_file##*/}"; base_name="${base_name%.xlsx}"; \
./target/debug/xlsx_2_icalc "$xlsx_file" "dist/frontend/models/${base_name}.ic"; \
done'
# ---------- server runtime ----------
FROM debian:bookworm-slim AS server-runtime
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy EVERYTHING you put in dist/server (binary + Rocket.toml + DB)
COPY --from=builder /app/dist/server/ ./
# Make sure Rocket binds to the container IP; explicitly point to the config file
ENV ROCKET_ADDRESS=0.0.0.0 \
ROCKET_PORT=8000 \
ROCKET_CONFIG=/app/Rocket.toml
EXPOSE 8000
# Run from /app so relative paths in Rocket.toml/DB work
CMD ["./ironcalc_server"]
# ---------- caddy runtime (serves frontend + reverse-proxy /api) ----------
FROM caddy:latest AS caddy-runtime
WORKDIR /srv
# Copy the frontend build output to /srv
COPY --from=builder /app/dist/frontend/ /srv/
# Copy the Caddyfile
COPY --from=builder /app/webapp/app.ironcalc.com/Caddyfile.compose /etc/caddy/Caddyfile

View File

@@ -31,7 +31,17 @@ This repository contains the main engine and the xlsx reader and writer.
Programmed in Rust, you will be able to use it from a variety of programming languages like Python, JavaScript (wasm), nodejs and possibly R, Julia or Go. Programmed in Rust, you will be able to use it from a variety of programming languages like Python, JavaScript (wasm), nodejs and possibly R, Julia or Go.
We will build different _skins_: in the terminal, as a desktop application or use it in you own web application. We will build different _skins_: in the terminal, as a desktop application or use it in your own web application.
# Docker
If you have docker installed just run:
```bash
docker compose up --build
```
head over to <http://localhost:2080> to test the application.
# Building # Building
@@ -84,7 +94,7 @@ And then use this code in `main.rs`:
```rust ```rust
use ironcalc::{ use ironcalc::{
base::{expressions::utils::number_to_column, model::Model}, base::{expressions::utils::number_to_column, Model},
export::save_to_xlsx, export::save_to_xlsx,
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 B

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 729 B

After

Width:  |  Height:  |  Size: 1019 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironcalc_base" name = "ironcalc_base"
version = "0.5.0" version = "0.6.0"
authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"] authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"]
edition = "2021" edition = "2021"
homepage = "https://www.ironcalc.com" homepage = "https://www.ironcalc.com"
@@ -17,9 +17,9 @@ chrono = "0.4"
chrono-tz = "0.10" chrono-tz = "0.10"
regex = { version = "1.0", optional = true} regex = { version = "1.0", optional = true}
regex-lite = { version = "0.1.6", optional = true} regex-lite = { version = "0.1.6", optional = true}
once_cell = "1.16.0"
bitcode = "0.6.3" bitcode = "0.6.3"
csv = "1.3.0" csv = "1.3.0"
statrs = { version = "0.18.0", default-features = false, features = [] }
[features] [features]
default = ["use_regex_full"] default = ["use_regex_full"]

View File

@@ -212,6 +212,12 @@ impl Model {
if column_count <= 0 { if column_count <= 0 {
return Err("Please use insert columns instead".to_string()); return Err("Please use insert columns instead".to_string());
} }
if !(1..=LAST_COLUMN).contains(&column) {
return Err(format!("Column number '{column}' is not valid."));
}
if column + column_count - 1 > LAST_COLUMN {
return Err("Cannot delete columns beyond the last column of the sheet".to_string());
}
// first column being deleted // first column being deleted
let column_start = column; let column_start = column;
@@ -384,6 +390,13 @@ impl Model {
if row_count <= 0 { if row_count <= 0 {
return Err("Please use insert rows instead".to_string()); return Err("Please use insert rows instead".to_string());
} }
if !(1..=LAST_ROW).contains(&row) {
return Err(format!("Row number '{row}' is not valid."));
}
if row + row_count - 1 > LAST_ROW {
return Err("Cannot delete rows beyond the last row of the sheet".to_string());
}
// Move cells // Move cells
let worksheet = &self.workbook.worksheet(sheet)?; let worksheet = &self.workbook.worksheet(sheet)?;
let mut all_rows: Vec<i32> = worksheet.sheet_data.keys().copied().collect(); let mut all_rows: Vec<i32> = worksheet.sheet_data.keys().copied().collect();
@@ -444,7 +457,7 @@ impl Model {
/// * Column is one of the extremes of the range. The new extreme would be target_column. /// * Column is one of the extremes of the range. The new extreme would be target_column.
/// Range is then normalized /// Range is then normalized
/// * Any other case, range is left unchanged. /// * Any other case, range is left unchanged.
/// NOTE: This does NOT move the data in the columns or move the colum styles /// NOTE: This moves the data and column styles along with the formulas
pub fn move_column_action( pub fn move_column_action(
&mut self, &mut self,
sheet: u32, sheet: u32,
@@ -460,7 +473,70 @@ impl Model {
return Err("Initial column out of boundaries".to_string()); return Err("Initial column out of boundaries".to_string());
} }
// TODO: Add the actual displacement of data and styles if delta == 0 {
return Ok(());
}
// Preserve cell contents, width and style of the column being moved
let original_refs = self
.workbook
.worksheet(sheet)?
.column_cell_references(column)?;
let mut original_cells = Vec::new();
for r in &original_refs {
let cell = self
.workbook
.worksheet(sheet)?
.cell(r.row, column)
.ok_or("Expected Cell to exist")?;
let style_idx = cell.get_style();
let formula_or_value = self
.get_cell_formula(sheet, r.row, column)?
.unwrap_or_else(|| cell.get_text(&self.workbook.shared_strings, &self.language));
original_cells.push((r.row, formula_or_value, style_idx));
self.cell_clear_all(sheet, r.row, column)?;
}
let width = self.workbook.worksheet(sheet)?.get_column_width(column)?;
let style = self.workbook.worksheet(sheet)?.get_column_style(column)?;
if delta > 0 {
for c in column + 1..=target_column {
let refs = self.workbook.worksheet(sheet)?.column_cell_references(c)?;
for r in refs {
self.move_cell(sheet, r.row, c, r.row, c - 1)?;
}
let w = self.workbook.worksheet(sheet)?.get_column_width(c)?;
let s = self.workbook.worksheet(sheet)?.get_column_style(c)?;
self.workbook
.worksheet_mut(sheet)?
.set_column_width_and_style(c - 1, w, s)?;
}
} else {
for c in (target_column..=column - 1).rev() {
let refs = self.workbook.worksheet(sheet)?.column_cell_references(c)?;
for r in refs {
self.move_cell(sheet, r.row, c, r.row, c + 1)?;
}
let w = self.workbook.worksheet(sheet)?.get_column_width(c)?;
let s = self.workbook.worksheet(sheet)?.get_column_style(c)?;
self.workbook
.worksheet_mut(sheet)?
.set_column_width_and_style(c + 1, w, s)?;
}
}
for (r, value, style_idx) in original_cells {
self.set_user_input(sheet, r, target_column, value)?;
self.workbook
.worksheet_mut(sheet)?
.set_cell_style(r, target_column, style_idx)?;
}
self.workbook
.worksheet_mut(sheet)?
.set_column_width_and_style(target_column, width, style)?;
// Update all formulas in the workbook // Update all formulas in the workbook
self.displace_cells( self.displace_cells(
@@ -473,4 +549,88 @@ impl Model {
Ok(()) Ok(())
} }
/// Displaces cells due to a move row action
/// from initial_row to target_row = initial_row + row_delta
/// References will be updated following the same rules as move_column_action
/// NOTE: This moves the data and row styles along with the formulas
pub fn move_row_action(&mut self, sheet: u32, row: i32, delta: i32) -> Result<(), String> {
// Check boundaries
let target_row = row + delta;
if !(1..=LAST_ROW).contains(&target_row) {
return Err("Target row out of boundaries".to_string());
}
if !(1..=LAST_ROW).contains(&row) {
return Err("Initial row out of boundaries".to_string());
}
if delta == 0 {
return Ok(());
}
let original_cols = self.get_columns_for_row(sheet, row, false)?;
let mut original_cells = Vec::new();
for c in &original_cols {
let cell = self
.workbook
.worksheet(sheet)?
.cell(row, *c)
.ok_or("Expected Cell to exist")?;
let style_idx = cell.get_style();
let formula_or_value = self
.get_cell_formula(sheet, row, *c)?
.unwrap_or_else(|| cell.get_text(&self.workbook.shared_strings, &self.language));
original_cells.push((*c, formula_or_value, style_idx));
self.cell_clear_all(sheet, row, *c)?;
}
if delta > 0 {
for r in row + 1..=target_row {
let cols = self.get_columns_for_row(sheet, r, false)?;
for c in cols {
self.move_cell(sheet, r, c, r - 1, c)?;
}
}
} else {
for r in (target_row..=row - 1).rev() {
let cols = self.get_columns_for_row(sheet, r, false)?;
for c in cols {
self.move_cell(sheet, r, c, r + 1, c)?;
}
}
}
for (c, value, style_idx) in original_cells {
self.set_user_input(sheet, target_row, c, value)?;
self.workbook
.worksheet_mut(sheet)?
.set_cell_style(target_row, c, style_idx)?;
}
let worksheet = &mut self.workbook.worksheet_mut(sheet)?;
let mut new_rows = Vec::new();
for r in worksheet.rows.iter() {
if r.r == row {
let mut nr = r.clone();
nr.r = target_row;
new_rows.push(nr);
} else if delta > 0 && r.r > row && r.r <= target_row {
let mut nr = r.clone();
nr.r -= 1;
new_rows.push(nr);
} else if delta < 0 && r.r < row && r.r >= target_row {
let mut nr = r.clone();
nr.r += 1;
new_rows.push(nr);
} else {
new_rows.push(r.clone());
}
}
worksheet.rows = new_rows;
// Update all formulas in the workbook
self.displace_cells(&(DisplaceData::RowMove { sheet, row, delta }))?;
Ok(())
}
} }

View File

@@ -5,6 +5,7 @@ use crate::{
token::Error, token::Error,
types::CellReferenceIndex, types::CellReferenceIndex,
}, },
formatter::format::parse_formatted_number,
model::Model, model::Model,
}; };
@@ -14,6 +15,23 @@ pub(crate) enum NumberOrArray {
} }
impl Model { impl Model {
pub(crate) fn cast_number(&self, s: &str) -> Option<f64> {
match s.trim().parse::<f64>() {
Ok(f) => Some(f),
_ => {
let currency = &self.locale.currency.symbol;
let mut currencies = vec!["$", ""];
if !currencies.iter().any(|e| *e == currency) {
currencies.push(currency);
}
// Try to parse as a formatted number (e.g., dates, currencies, percentages)
if let Ok((v, _number_format)) = parse_formatted_number(s, &currencies) {
return Some(v);
}
None
}
}
}
pub(crate) fn get_number_or_array( pub(crate) fn get_number_or_array(
&mut self, &mut self,
node: &Node, node: &Node,
@@ -21,9 +39,9 @@ impl Model {
) -> Result<NumberOrArray, CalcResult> { ) -> Result<NumberOrArray, CalcResult> {
match self.evaluate_node_in_context(node, cell) { match self.evaluate_node_in_context(node, cell) {
CalcResult::Number(f) => Ok(NumberOrArray::Number(f)), CalcResult::Number(f) => Ok(NumberOrArray::Number(f)),
CalcResult::String(s) => match s.parse::<f64>() { CalcResult::String(s) => match self.cast_number(&s) {
Ok(f) => Ok(NumberOrArray::Number(f)), Some(f) => Ok(NumberOrArray::Number(f)),
_ => Err(CalcResult::new_error( None => Err(CalcResult::new_error(
Error::VALUE, Error::VALUE,
cell, cell,
"Expecting number".to_string(), "Expecting number".to_string(),
@@ -89,16 +107,16 @@ impl Model {
self.cast_to_number(result, cell) self.cast_to_number(result, cell)
} }
fn cast_to_number( pub(crate) fn cast_to_number(
&mut self, &mut self,
result: CalcResult, result: CalcResult,
cell: CellReferenceIndex, cell: CellReferenceIndex,
) -> Result<f64, CalcResult> { ) -> Result<f64, CalcResult> {
match result { match result {
CalcResult::Number(f) => Ok(f), CalcResult::Number(f) => Ok(f),
CalcResult::String(s) => match s.parse::<f64>() { CalcResult::String(s) => match self.cast_number(&s) {
Ok(f) => Ok(f), Some(f) => Ok(f),
_ => Err(CalcResult::new_error( None => Err(CalcResult::new_error(
Error::VALUE, Error::VALUE,
cell, cell,
"Expecting number".to_string(), "Expecting number".to_string(),

View File

@@ -314,6 +314,9 @@ impl Lexer {
} else if name_upper == self.language.booleans.r#false { } else if name_upper == self.language.booleans.r#false {
return TokenType::Boolean(false); return TokenType::Boolean(false);
} }
if self.peek_char() == Some('(') {
return TokenType::Ident(name);
}
if self.mode == LexerMode::A1 { if self.mode == LexerMode::A1 {
let parsed_reference = utils::parse_reference_a1(&name_upper); let parsed_reference = utils::parse_reference_a1(&name_upper);
if parsed_reference.is_some() if parsed_reference.is_some()

View File

@@ -1,5 +1,6 @@
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]
use crate::expressions::utils::column_to_number;
use crate::language::get_language; use crate::language::get_language;
use crate::locale::get_locale; use crate::locale::get_locale;
@@ -685,3 +686,29 @@ fn test_comparisons() {
assert_eq!(lx.next_token(), Number(7.0)); assert_eq!(lx.next_token(), Number(7.0));
assert_eq!(lx.next_token(), EOF); assert_eq!(lx.next_token(), EOF);
} }
#[test]
fn test_log10_is_cell_reference() {
let mut lx = new_lexer("LOG10", true);
assert_eq!(
lx.next_token(),
Reference {
sheet: None,
column: column_to_number("LOG").unwrap(),
row: 10,
absolute_column: false,
absolute_row: false,
}
);
assert_eq!(lx.next_token(), EOF);
}
#[test]
fn test_log10_is_function() {
let mut lx = new_lexer("LOG10(100)", true);
assert_eq!(lx.next_token(), Ident("LOG10".to_string()));
assert_eq!(lx.next_token(), LeftParenthesis);
assert_eq!(lx.next_token(), Number(100.0));
assert_eq!(lx.next_token(), RightParenthesis);
assert_eq!(lx.next_token(), EOF);
}

View File

@@ -717,7 +717,7 @@ impl Parser {
return Node::ParseErrorKind { return Node::ParseErrorKind {
formula: self.lexer.get_formula(), formula: self.lexer.get_formula(),
position: 0, position: 0,
message: "sheet not found".to_string(), message: format!("sheet not found: {}", context.sheet),
}; };
} }
}; };
@@ -850,7 +850,7 @@ impl Parser {
return Node::ParseErrorKind { return Node::ParseErrorKind {
formula: self.lexer.get_formula(), formula: self.lexer.get_formula(),
position: 0, position: 0,
message: "sheet not found".to_string(), message: format!("sheet not found: {}", context.sheet),
}; };
} }
}; };
@@ -878,7 +878,7 @@ impl Parser {
return Node::ParseErrorKind { return Node::ParseErrorKind {
formula: self.lexer.get_formula(), formula: self.lexer.get_formula(),
position: 0, position: 0,
message: "sheet not found".to_string(), message: format!("table sheet not found: {}", table.sheet_name),
}; };
} }
}; };

View File

@@ -2,15 +2,19 @@ use crate::functions::Function;
use super::Node; use super::Node;
use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use std::sync::OnceLock;
static RANGE_REFERENCE_REGEX: OnceLock<Regex> = OnceLock::new();
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
static RE: Lazy<Regex> = fn get_re() -> &'static Regex {
Lazy::new(|| Regex::new(r":[A-Z]*[0-9]*$").expect("Regex is known to be valid")); RANGE_REFERENCE_REGEX
.get_or_init(|| Regex::new(r":[A-Z]*[0-9]*$").expect("Regex is known to be valid"))
}
fn is_range_reference(s: &str) -> bool { fn is_range_reference(s: &str) -> bool {
RE.is_match(s) get_re().is_match(s)
} }
/* /*
@@ -337,7 +341,8 @@ fn static_analysis_offset(args: &[Node]) -> StaticResult {
} }
_ => return StaticResult::Unknown, _ => return StaticResult::Unknown,
}; };
StaticResult::Unknown // Both height and width are explicitly 1, so OFFSET will return a single cell
StaticResult::Scalar
} }
// fn static_analysis_choose(_args: &[Node]) -> StaticResult { // fn static_analysis_choose(_args: &[Node]) -> StaticResult {
@@ -571,6 +576,37 @@ fn args_signature_xnpv(arg_count: usize) -> Vec<Signature> {
} }
} }
// NETWORKDAYS(start_date, end_date, [holidays])
// Parameters: start_date (scalar), end_date (scalar), holidays (optional vector)
fn args_signature_networkdays(arg_count: usize) -> Vec<Signature> {
if arg_count == 2 {
vec![Signature::Scalar, Signature::Scalar]
} else if arg_count == 3 {
vec![Signature::Scalar, Signature::Scalar, Signature::Vector]
} else {
vec![Signature::Error; arg_count]
}
}
// NETWORKDAYS.INTL(start_date, end_date, [weekend], [holidays])
// Parameters: start_date (scalar), end_date (scalar), weekend (optional scalar), holidays (optional vector)
fn args_signature_networkdays_intl(arg_count: usize) -> Vec<Signature> {
if arg_count == 2 {
vec![Signature::Scalar, Signature::Scalar]
} else if arg_count == 3 {
vec![Signature::Scalar, Signature::Scalar, Signature::Scalar]
} else if arg_count == 4 {
vec![
Signature::Scalar,
Signature::Scalar,
Signature::Scalar,
Signature::Vector,
]
} else {
vec![Signature::Error; arg_count]
}
}
// FIXME: This is terrible duplications of efforts. We use the signature in at least three different places: // FIXME: This is terrible duplications of efforts. We use the signature in at least three different places:
// 1. When computing the function // 1. When computing the function
// 2. Checking the arguments to see if we need to insert the implicit intersection operator // 2. Checking the arguments to see if we need to insert the implicit intersection operator
@@ -605,6 +641,9 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Choose => vec![Signature::Scalar; arg_count], Function::Choose => vec![Signature::Scalar; arg_count],
Function::Column => args_signature_row(arg_count), Function::Column => args_signature_row(arg_count),
Function::Columns => args_signature_one_vector(arg_count), Function::Columns => args_signature_one_vector(arg_count),
Function::Ln => args_signature_scalars(arg_count, 1, 0),
Function::Log => args_signature_scalars(arg_count, 1, 1),
Function::Log10 => args_signature_scalars(arg_count, 1, 0),
Function::Cos => args_signature_scalars(arg_count, 1, 0), Function::Cos => args_signature_scalars(arg_count, 1, 0),
Function::Cosh => args_signature_scalars(arg_count, 1, 0), Function::Cosh => args_signature_scalars(arg_count, 1, 0),
Function::Max => vec![Signature::Vector; arg_count], Function::Max => vec![Signature::Vector; arg_count],
@@ -672,6 +711,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Value => args_signature_scalars(arg_count, 1, 0), Function::Value => args_signature_scalars(arg_count, 1, 0),
Function::Valuetotext => args_signature_scalars(arg_count, 1, 1), Function::Valuetotext => args_signature_scalars(arg_count, 1, 1),
Function::Average => vec![Signature::Vector; arg_count], Function::Average => vec![Signature::Vector; arg_count],
Function::Avedev => vec![Signature::Vector; arg_count],
Function::Averagea => vec![Signature::Vector; arg_count], Function::Averagea => vec![Signature::Vector; arg_count],
Function::Averageif => args_signature_sumif(arg_count), Function::Averageif => args_signature_sumif(arg_count),
Function::Averageifs => vec![Signature::Vector; arg_count], Function::Averageifs => vec![Signature::Vector; arg_count],
@@ -683,13 +723,28 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Maxifs => vec![Signature::Vector; arg_count], Function::Maxifs => vec![Signature::Vector; arg_count],
Function::Minifs => vec![Signature::Vector; arg_count], Function::Minifs => vec![Signature::Vector; arg_count],
Function::Date => args_signature_scalars(arg_count, 3, 0), Function::Date => args_signature_scalars(arg_count, 3, 0),
Function::Datedif => args_signature_scalars(arg_count, 3, 0),
Function::Datevalue => args_signature_scalars(arg_count, 1, 0),
Function::Day => args_signature_scalars(arg_count, 1, 0), Function::Day => args_signature_scalars(arg_count, 1, 0),
Function::Edate => args_signature_scalars(arg_count, 2, 0), Function::Edate => args_signature_scalars(arg_count, 2, 0),
Function::Eomonth => args_signature_scalars(arg_count, 2, 0), Function::Eomonth => args_signature_scalars(arg_count, 2, 0),
Function::Month => args_signature_scalars(arg_count, 1, 0), Function::Month => args_signature_scalars(arg_count, 1, 0),
Function::Time => args_signature_scalars(arg_count, 3, 0),
Function::Timevalue => args_signature_scalars(arg_count, 1, 0),
Function::Hour => args_signature_scalars(arg_count, 1, 0),
Function::Minute => args_signature_scalars(arg_count, 1, 0),
Function::Second => args_signature_scalars(arg_count, 1, 0),
Function::Now => args_signature_no_args(arg_count), Function::Now => args_signature_no_args(arg_count),
Function::Today => args_signature_no_args(arg_count), Function::Today => args_signature_no_args(arg_count),
Function::Year => args_signature_scalars(arg_count, 1, 0), Function::Year => args_signature_scalars(arg_count, 1, 0),
Function::Days => args_signature_scalars(arg_count, 2, 0),
Function::Days360 => args_signature_scalars(arg_count, 2, 1),
Function::Weekday => args_signature_scalars(arg_count, 1, 1),
Function::Weeknum => args_signature_scalars(arg_count, 1, 1),
Function::Workday => args_signature_scalars(arg_count, 2, 1),
Function::WorkdayIntl => args_signature_scalars(arg_count, 2, 2),
Function::Yearfrac => args_signature_scalars(arg_count, 2, 1),
Function::Isoweeknum => args_signature_scalars(arg_count, 1, 0),
Function::Cumipmt => args_signature_scalars(arg_count, 6, 0), Function::Cumipmt => args_signature_scalars(arg_count, 6, 0),
Function::Cumprinc => args_signature_scalars(arg_count, 6, 0), Function::Cumprinc => args_signature_scalars(arg_count, 6, 0),
Function::Db => args_signature_scalars(arg_count, 4, 1), Function::Db => args_signature_scalars(arg_count, 4, 1),
@@ -778,6 +833,162 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Formulatext => args_signature_scalars(arg_count, 1, 0), Function::Formulatext => args_signature_scalars(arg_count, 1, 0),
Function::Unicode => args_signature_scalars(arg_count, 1, 0), Function::Unicode => args_signature_scalars(arg_count, 1, 0),
Function::Geomean => vec![Signature::Vector; arg_count], Function::Geomean => vec![Signature::Vector; arg_count],
Function::Networkdays => args_signature_networkdays(arg_count),
Function::NetworkdaysIntl => args_signature_networkdays_intl(arg_count),
Function::Acot => args_signature_scalars(arg_count, 1, 0),
Function::Acoth => args_signature_scalars(arg_count, 1, 0),
Function::Cot => args_signature_scalars(arg_count, 1, 0),
Function::Coth => args_signature_scalars(arg_count, 1, 0),
Function::Csc => args_signature_scalars(arg_count, 1, 0),
Function::Csch => args_signature_scalars(arg_count, 1, 0),
Function::Sec => args_signature_scalars(arg_count, 1, 0),
Function::Sech => args_signature_scalars(arg_count, 1, 0),
Function::Exp => args_signature_scalars(arg_count, 1, 0),
Function::Fact => args_signature_scalars(arg_count, 1, 0),
Function::Factdouble => args_signature_scalars(arg_count, 1, 0),
Function::Sign => args_signature_scalars(arg_count, 1, 0),
Function::Radians => args_signature_scalars(arg_count, 1, 0),
Function::Degrees => args_signature_scalars(arg_count, 1, 0),
Function::Int => args_signature_scalars(arg_count, 1, 0),
Function::Even => args_signature_scalars(arg_count, 1, 0),
Function::Odd => args_signature_scalars(arg_count, 1, 0),
Function::Ceiling => args_signature_scalars(arg_count, 2, 0),
Function::CeilingMath => args_signature_scalars(arg_count, 1, 2),
Function::CeilingPrecise => args_signature_scalars(arg_count, 1, 1),
Function::Floor => args_signature_scalars(arg_count, 2, 0),
Function::FloorMath => args_signature_scalars(arg_count, 1, 2),
Function::FloorPrecise => args_signature_scalars(arg_count, 1, 1),
Function::IsoCeiling => args_signature_scalars(arg_count, 1, 1),
Function::Mod => args_signature_scalars(arg_count, 2, 0),
Function::Quotient => args_signature_scalars(arg_count, 2, 0),
Function::Mround => args_signature_scalars(arg_count, 2, 0),
Function::Trunc => args_signature_scalars(arg_count, 1, 1),
Function::Gcd => vec![Signature::Vector; arg_count],
Function::Lcm => vec![Signature::Vector; arg_count],
Function::Base => args_signature_scalars(arg_count, 2, 1),
Function::Decimal => args_signature_scalars(arg_count, 2, 0),
Function::Roman => args_signature_scalars(arg_count, 1, 1),
Function::Arabic => args_signature_scalars(arg_count, 1, 0),
Function::Combin => args_signature_scalars(arg_count, 2, 0),
Function::Combina => args_signature_scalars(arg_count, 2, 0),
Function::Sumsq => vec![Signature::Vector; arg_count],
Function::N => args_signature_scalars(arg_count, 1, 0),
Function::Sheets => args_signature_scalars(arg_count, 0, 1),
Function::Cell => args_signature_scalars(arg_count, 1, 1),
Function::Info => args_signature_scalars(arg_count, 1, 1),
Function::Daverage => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dcount => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dget => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dmax => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dmin => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dsum => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dcounta => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dproduct => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dstdev => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dvar => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dvarp => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dstdevp => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::BetaDist => args_signature_scalars(arg_count, 4, 2),
Function::BetaInv => args_signature_scalars(arg_count, 3, 2),
Function::BinomDist => args_signature_scalars(arg_count, 4, 0),
Function::BinomDistRange => args_signature_scalars(arg_count, 3, 1),
Function::BinomInv => args_signature_scalars(arg_count, 3, 0),
Function::ChisqDist => args_signature_scalars(arg_count, 4, 0),
Function::ChisqDistRT => args_signature_scalars(arg_count, 3, 0),
Function::ChisqInv => args_signature_scalars(arg_count, 3, 0),
Function::ChisqInvRT => args_signature_scalars(arg_count, 2, 0),
Function::ChisqTest => {
if arg_count == 2 {
vec![Signature::Vector, Signature::Vector]
} else {
vec![Signature::Error; arg_count]
}
}
Function::ConfidenceNorm => args_signature_scalars(arg_count, 3, 0),
Function::ConfidenceT => args_signature_scalars(arg_count, 3, 0),
Function::CovarianceP => {
if arg_count == 2 {
vec![Signature::Vector, Signature::Vector]
} else {
vec![Signature::Error; arg_count]
}
}
Function::CovarianceS => {
if arg_count == 2 {
vec![Signature::Vector, Signature::Vector]
} else {
vec![Signature::Error; arg_count]
}
}
Function::Devsq => vec![Signature::Vector; arg_count],
Function::ExponDist => args_signature_scalars(arg_count, 3, 0),
Function::FDist => args_signature_scalars(arg_count, 4, 0),
Function::FDistRT => args_signature_scalars(arg_count, 3, 0),
Function::FInv => args_signature_scalars(arg_count, 3, 0),
Function::FInvRT => args_signature_scalars(arg_count, 3, 0),
Function::Fisher => args_signature_scalars(arg_count, 1, 0),
Function::FisherInv => args_signature_scalars(arg_count, 1, 0),
Function::Gamma => args_signature_scalars(arg_count, 1, 0),
Function::GammaDist => args_signature_scalars(arg_count, 4, 0),
Function::GammaInv => args_signature_scalars(arg_count, 3, 0),
Function::GammaLn => args_signature_scalars(arg_count, 1, 0),
Function::GammaLnPrecise => args_signature_scalars(arg_count, 1, 0),
Function::HypGeomDist => args_signature_scalars(arg_count, 5, 0),
Function::LogNormDist => args_signature_scalars(arg_count, 4, 0),
Function::LogNormInv => args_signature_scalars(arg_count, 3, 0),
Function::NegbinomDist => args_signature_scalars(arg_count, 4, 0),
Function::NormDist => args_signature_scalars(arg_count, 4, 0),
Function::NormInv => args_signature_scalars(arg_count, 3, 0),
Function::NormSdist => args_signature_scalars(arg_count, 2, 0),
Function::NormSInv => args_signature_scalars(arg_count, 1, 0),
Function::Pearson => {
if arg_count == 2 {
vec![Signature::Vector, Signature::Vector]
} else {
vec![Signature::Error; arg_count]
}
}
Function::Phi => args_signature_scalars(arg_count, 1, 0),
Function::PoissonDist => args_signature_scalars(arg_count, 3, 0),
Function::Standardize => args_signature_scalars(arg_count, 3, 0),
Function::StDevP => vec![Signature::Vector; arg_count],
Function::StDevS => vec![Signature::Vector; arg_count],
Function::Stdeva => vec![Signature::Vector; arg_count],
Function::Stdevpa => vec![Signature::Vector; arg_count],
Function::TDist => args_signature_scalars(arg_count, 3, 0),
Function::TDist2T => args_signature_scalars(arg_count, 2, 0),
Function::TDistRT => args_signature_scalars(arg_count, 2, 0),
Function::TInv => args_signature_scalars(arg_count, 2, 0),
Function::TInv2T => args_signature_scalars(arg_count, 2, 0),
Function::TTest => {
if arg_count == 4 {
vec![
Signature::Vector,
Signature::Vector,
Signature::Scalar,
Signature::Scalar,
]
} else {
vec![Signature::Error; arg_count]
}
}
Function::VarP => vec![Signature::Vector; arg_count],
Function::VarS => vec![Signature::Vector; arg_count],
Function::VarpA => vec![Signature::Vector; arg_count],
Function::VarA => vec![Signature::Vector; arg_count],
Function::WeibullDist => args_signature_scalars(arg_count, 4, 0),
Function::ZTest => {
if arg_count == 2 {
vec![Signature::Vector, Signature::Scalar]
} else if arg_count == 3 {
vec![Signature::Vector, Signature::Scalar, Signature::Scalar]
} else {
vec![Signature::Error; arg_count]
}
}
} }
} }
@@ -816,6 +1027,9 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Round => scalar_arguments(args), Function::Round => scalar_arguments(args),
Function::Rounddown => scalar_arguments(args), Function::Rounddown => scalar_arguments(args),
Function::Roundup => scalar_arguments(args), Function::Roundup => scalar_arguments(args),
Function::Ln => scalar_arguments(args),
Function::Log => scalar_arguments(args),
Function::Log10 => scalar_arguments(args),
Function::Sin => scalar_arguments(args), Function::Sin => scalar_arguments(args),
Function::Sinh => scalar_arguments(args), Function::Sinh => scalar_arguments(args),
Function::Sqrt => scalar_arguments(args), Function::Sqrt => scalar_arguments(args),
@@ -876,6 +1090,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Valuetotext => not_implemented(args), Function::Valuetotext => not_implemented(args),
Function::Average => not_implemented(args), Function::Average => not_implemented(args),
Function::Averagea => not_implemented(args), Function::Averagea => not_implemented(args),
Function::Avedev => not_implemented(args),
Function::Averageif => not_implemented(args), Function::Averageif => not_implemented(args),
Function::Averageifs => not_implemented(args), Function::Averageifs => not_implemented(args),
Function::Count => not_implemented(args), Function::Count => not_implemented(args),
@@ -886,12 +1101,27 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Maxifs => not_implemented(args), Function::Maxifs => not_implemented(args),
Function::Minifs => not_implemented(args), Function::Minifs => not_implemented(args),
Function::Date => not_implemented(args), Function::Date => not_implemented(args),
Function::Datedif => not_implemented(args),
Function::Datevalue => not_implemented(args),
Function::Day => not_implemented(args), Function::Day => not_implemented(args),
Function::Edate => not_implemented(args), Function::Edate => not_implemented(args),
Function::Month => not_implemented(args), Function::Month => not_implemented(args),
Function::Time => not_implemented(args),
Function::Timevalue => not_implemented(args),
Function::Hour => not_implemented(args),
Function::Minute => not_implemented(args),
Function::Second => not_implemented(args),
Function::Now => not_implemented(args), Function::Now => not_implemented(args),
Function::Today => not_implemented(args), Function::Today => not_implemented(args),
Function::Year => not_implemented(args), Function::Year => not_implemented(args),
Function::Days => not_implemented(args),
Function::Days360 => not_implemented(args),
Function::Weekday => not_implemented(args),
Function::Weeknum => not_implemented(args),
Function::Workday => not_implemented(args),
Function::WorkdayIntl => not_implemented(args),
Function::Yearfrac => not_implemented(args),
Function::Isoweeknum => not_implemented(args),
Function::Cumipmt => not_implemented(args), Function::Cumipmt => not_implemented(args),
Function::Cumprinc => not_implemented(args), Function::Cumprinc => not_implemented(args),
Function::Db => not_implemented(args), Function::Db => not_implemented(args),
@@ -980,5 +1210,117 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Eomonth => scalar_arguments(args), Function::Eomonth => scalar_arguments(args),
Function::Formulatext => not_implemented(args), Function::Formulatext => not_implemented(args),
Function::Geomean => not_implemented(args), Function::Geomean => not_implemented(args),
Function::Networkdays => not_implemented(args),
Function::NetworkdaysIntl => not_implemented(args),
Function::Acot => scalar_arguments(args),
Function::Acoth => scalar_arguments(args),
Function::Cot => scalar_arguments(args),
Function::Coth => scalar_arguments(args),
Function::Csc => scalar_arguments(args),
Function::Csch => scalar_arguments(args),
Function::Sec => scalar_arguments(args),
Function::Sech => scalar_arguments(args),
Function::Exp => scalar_arguments(args),
Function::Fact => scalar_arguments(args),
Function::Factdouble => scalar_arguments(args),
Function::Sign => scalar_arguments(args),
Function::Radians => scalar_arguments(args),
Function::Degrees => scalar_arguments(args),
Function::Int => scalar_arguments(args),
Function::Even => scalar_arguments(args),
Function::Odd => scalar_arguments(args),
Function::Ceiling => scalar_arguments(args),
Function::CeilingMath => scalar_arguments(args),
Function::CeilingPrecise => scalar_arguments(args),
Function::Floor => scalar_arguments(args),
Function::FloorMath => scalar_arguments(args),
Function::FloorPrecise => scalar_arguments(args),
Function::IsoCeiling => scalar_arguments(args),
Function::Mod => scalar_arguments(args),
Function::Quotient => scalar_arguments(args),
Function::Mround => scalar_arguments(args),
Function::Trunc => scalar_arguments(args),
Function::Gcd => not_implemented(args),
Function::Lcm => not_implemented(args),
Function::Base => scalar_arguments(args),
Function::Decimal => scalar_arguments(args),
Function::Roman => scalar_arguments(args),
Function::Arabic => scalar_arguments(args),
Function::Combin => scalar_arguments(args),
Function::Combina => scalar_arguments(args),
Function::Sumsq => StaticResult::Scalar,
Function::N => scalar_arguments(args),
Function::Sheets => scalar_arguments(args),
Function::Cell => scalar_arguments(args),
Function::Info => scalar_arguments(args),
Function::Dget => not_implemented(args),
Function::Dmax => not_implemented(args),
Function::Dmin => not_implemented(args),
Function::Dcount => not_implemented(args),
Function::Daverage => not_implemented(args),
Function::Dsum => not_implemented(args),
Function::Dcounta => not_implemented(args),
Function::Dproduct => not_implemented(args),
Function::Dstdev => not_implemented(args),
Function::Dvar => not_implemented(args),
Function::Dvarp => not_implemented(args),
Function::Dstdevp => not_implemented(args),
Function::BetaDist => StaticResult::Scalar,
Function::BetaInv => StaticResult::Scalar,
Function::BinomDist => StaticResult::Scalar,
Function::BinomDistRange => StaticResult::Scalar,
Function::BinomInv => StaticResult::Scalar,
Function::ChisqDist => StaticResult::Scalar,
Function::ChisqDistRT => StaticResult::Scalar,
Function::ChisqInv => StaticResult::Scalar,
Function::ChisqInvRT => StaticResult::Scalar,
Function::ChisqTest => StaticResult::Scalar,
Function::ConfidenceNorm => StaticResult::Scalar,
Function::ConfidenceT => StaticResult::Scalar,
Function::CovarianceP => StaticResult::Scalar,
Function::CovarianceS => StaticResult::Scalar,
Function::Devsq => StaticResult::Scalar,
Function::ExponDist => StaticResult::Scalar,
Function::FDist => StaticResult::Scalar,
Function::FDistRT => StaticResult::Scalar,
Function::FInv => StaticResult::Scalar,
Function::FInvRT => StaticResult::Scalar,
Function::Fisher => StaticResult::Scalar,
Function::FisherInv => StaticResult::Scalar,
Function::Gamma => StaticResult::Scalar,
Function::GammaDist => StaticResult::Scalar,
Function::GammaInv => StaticResult::Scalar,
Function::GammaLn => StaticResult::Scalar,
Function::GammaLnPrecise => StaticResult::Scalar,
Function::HypGeomDist => StaticResult::Scalar,
Function::LogNormDist => StaticResult::Scalar,
Function::LogNormInv => StaticResult::Scalar,
Function::NegbinomDist => StaticResult::Scalar,
Function::NormDist => StaticResult::Scalar,
Function::NormInv => StaticResult::Scalar,
Function::NormSdist => StaticResult::Scalar,
Function::NormSInv => StaticResult::Scalar,
Function::Pearson => StaticResult::Scalar,
Function::Phi => StaticResult::Scalar,
Function::PoissonDist => StaticResult::Scalar,
Function::Standardize => StaticResult::Scalar,
Function::StDevP => StaticResult::Scalar,
Function::StDevS => StaticResult::Scalar,
Function::Stdeva => StaticResult::Scalar,
Function::Stdevpa => StaticResult::Scalar,
Function::TDist => StaticResult::Scalar,
Function::TDist2T => StaticResult::Scalar,
Function::TDistRT => StaticResult::Scalar,
Function::TInv => StaticResult::Scalar,
Function::TInv2T => StaticResult::Scalar,
Function::TTest => StaticResult::Scalar,
Function::VarP => StaticResult::Scalar,
Function::VarS => StaticResult::Scalar,
Function::VarpA => StaticResult::Scalar,
Function::VarA => StaticResult::Scalar,
Function::WeibullDist => StaticResult::Scalar,
Function::ZTest => StaticResult::Scalar,
} }
} }

View File

@@ -2,7 +2,7 @@ use super::{super::utils::quote_name, Node, Reference};
use crate::constants::{LAST_COLUMN, LAST_ROW}; use crate::constants::{LAST_COLUMN, LAST_ROW};
use crate::expressions::parser::move_formula::to_string_array_node; use crate::expressions::parser::move_formula::to_string_array_node;
use crate::expressions::parser::static_analysis::add_implicit_intersection; use crate::expressions::parser::static_analysis::add_implicit_intersection;
use crate::expressions::token::OpUnary; use crate::expressions::token::{OpSum, OpUnary};
use crate::{expressions::types::CellReferenceRC, number_format::to_excel_precision_str}; use crate::{expressions::types::CellReferenceRC, number_format::to_excel_precision_str};
pub enum DisplaceData { pub enum DisplaceData {
@@ -28,6 +28,11 @@ pub enum DisplaceData {
column: i32, column: i32,
delta: i32, delta: i32,
}, },
RowMove {
sheet: u32,
row: i32,
delta: i32,
},
ColumnMove { ColumnMove {
sheet: u32, sheet: u32,
column: i32, column: i32,
@@ -159,6 +164,29 @@ pub(crate) fn stringify_reference(
} }
} }
} }
DisplaceData::RowMove {
sheet,
row: move_row,
delta,
} => {
if sheet_index == *sheet {
if row == *move_row {
row += *delta;
} else if *delta > 0 {
// Moving the row downwards
if row > *move_row && row <= *move_row + *delta {
// Intermediate rows move up by one position
row -= 1;
}
} else if *delta < 0 {
// Moving the row upwards
if row < *move_row && row >= *move_row + *delta {
// Intermediate rows move down by one position
row += 1;
}
}
}
}
DisplaceData::ColumnMove { DisplaceData::ColumnMove {
sheet, sheet,
column: move_column, column: move_column,
@@ -167,14 +195,18 @@ pub(crate) fn stringify_reference(
if sheet_index == *sheet { if sheet_index == *sheet {
if column == *move_column { if column == *move_column {
column += *delta; column += *delta;
} else if (*delta > 0 } else if *delta > 0 {
&& column > *move_column // Moving the column to the right
&& column <= *move_column + *delta) if column > *move_column && column <= *move_column + *delta {
|| (*delta < 0 // Intermediate columns move left by one position
&& column < *move_column column -= 1;
&& column >= *move_column + *delta) }
{ } else if *delta < 0 {
column -= *delta; // Moving the column to the left
if column < *move_column && column >= *move_column + *delta {
// Intermediate columns move right by one position
column += 1;
}
} }
} }
} }
@@ -451,34 +483,32 @@ fn stringify(
kind, kind,
stringify(right, context, displace_data, export_to_excel) stringify(right, context, displace_data, export_to_excel)
), ),
OpSumKind { kind, left, right } => format!( OpSumKind { kind, left, right } => {
"{}{}{}", let left_str = stringify(left, context, displace_data, export_to_excel);
stringify(left, context, displace_data, export_to_excel), // if kind is minus then we need parentheses in the right side if they are OpSumKind or CompareKind
kind, let right_str = if (matches!(kind, OpSum::Minus) && matches!(**right, OpSumKind { .. }))
stringify(right, context, displace_data, export_to_excel) | matches!(**right, CompareKind { .. })
), {
format!(
"({})",
stringify(right, context, displace_data, export_to_excel)
)
} else {
stringify(right, context, displace_data, export_to_excel)
};
format!("{left_str}{kind}{right_str}")
}
OpProductKind { kind, left, right } => { OpProductKind { kind, left, right } => {
let x = match **left { let x = match **left {
OpSumKind { .. } => format!( OpSumKind { .. } | CompareKind { .. } => format!(
"({})",
stringify(left, context, displace_data, export_to_excel)
),
CompareKind { .. } => format!(
"({})", "({})",
stringify(left, context, displace_data, export_to_excel) stringify(left, context, displace_data, export_to_excel)
), ),
_ => stringify(left, context, displace_data, export_to_excel), _ => stringify(left, context, displace_data, export_to_excel),
}; };
let y = match **right { let y = match **right {
OpSumKind { .. } => format!( OpSumKind { .. } | CompareKind { .. } | OpProductKind { .. } => format!(
"({})",
stringify(right, context, displace_data, export_to_excel)
),
CompareKind { .. } => format!(
"({})",
stringify(right, context, displace_data, export_to_excel)
),
OpProductKind { .. } => format!(
"({})", "({})",
stringify(right, context, displace_data, export_to_excel) stringify(right, context, displace_data, export_to_excel)
), ),
@@ -490,6 +520,7 @@ fn stringify(
let x = match **left { let x = match **left {
BooleanKind(_) BooleanKind(_)
| NumberKind(_) | NumberKind(_)
| UnaryKind { .. }
| StringKind(_) | StringKind(_)
| ReferenceKind { .. } | ReferenceKind { .. }
| RangeKind { .. } | RangeKind { .. }
@@ -505,7 +536,6 @@ fn stringify(
| FunctionKind { .. } | FunctionKind { .. }
| InvalidFunctionKind { .. } | InvalidFunctionKind { .. }
| ArrayKind(_) | ArrayKind(_)
| UnaryKind { .. }
| ErrorKind(_) | ErrorKind(_)
| ParseErrorKind { .. } | ParseErrorKind { .. }
| OpSumKind { .. } | OpSumKind { .. }
@@ -589,10 +619,42 @@ fn stringify(
WrongVariableKind(name) => name.to_string(), WrongVariableKind(name) => name.to_string(),
UnaryKind { kind, right } => match kind { UnaryKind { kind, right } => match kind {
OpUnary::Minus => { OpUnary::Minus => {
format!( let needs_parentheses = match **right {
"-{}", BooleanKind(_)
stringify(right, context, displace_data, export_to_excel) | NumberKind(_)
) | StringKind(_)
| ReferenceKind { .. }
| RangeKind { .. }
| WrongReferenceKind { .. }
| WrongRangeKind { .. }
| OpRangeKind { .. }
| OpConcatenateKind { .. }
| OpProductKind { .. }
| FunctionKind { .. }
| InvalidFunctionKind { .. }
| ArrayKind(_)
| DefinedNameKind(_)
| TableNameKind(_)
| WrongVariableKind(_)
| ImplicitIntersection { .. }
| CompareKind { .. }
| ErrorKind(_)
| ParseErrorKind { .. }
| EmptyArgKind => false,
OpPowerKind { .. } | OpSumKind { .. } | UnaryKind { .. } => true,
};
if needs_parentheses {
format!(
"-({})",
stringify(right, context, displace_data, export_to_excel)
)
} else {
format!(
"-{}",
stringify(right, context, displace_data, export_to_excel)
)
}
} }
OpUnary::Percentage => { OpUnary::Percentage => {
format!( format!(

View File

@@ -3,6 +3,7 @@ mod test_arrays;
mod test_general; mod test_general;
mod test_implicit_intersection; mod test_implicit_intersection;
mod test_issue_155; mod test_issue_155;
mod test_issue_483;
mod test_move_formula; mod test_move_formula;
mod test_ranges; mod test_ranges;
mod test_stringify; mod test_stringify;

View File

@@ -0,0 +1,27 @@
#![allow(clippy::panic)]
use std::collections::HashMap;
use crate::expressions::parser::stringify::to_string;
use crate::expressions::parser::{Node, Parser};
use crate::expressions::types::CellReferenceRC;
#[test]
fn issue_483_parser() {
let worksheets = vec!["Sheet1".to_string()];
let mut parser = Parser::new(worksheets, vec![], HashMap::new());
// Reference cell is Sheet1!A1
let cell_reference = CellReferenceRC {
sheet: "Sheet1".to_string(),
row: 2,
column: 2,
};
let t = parser.parse("-(A1^1.22)", &cell_reference);
assert!(matches!(t, Node::UnaryKind { .. }));
assert_eq!(to_string(&t, &cell_reference), "-(A1^1.22)");
let t = parser.parse("-A1^1.22", &cell_reference);
assert!(matches!(t, Node::OpPowerKind { .. }));
assert_eq!(to_string(&t, &cell_reference), "-A1^1.22");
}

View File

@@ -32,3 +32,39 @@ fn exp_order() {
let t = parser.parse("(5)^(4)", &cell_reference); let t = parser.parse("(5)^(4)", &cell_reference);
assert_eq!(to_string(&t, &cell_reference), "5^4"); assert_eq!(to_string(&t, &cell_reference), "5^4");
} }
#[test]
fn correct_parenthesis() {
let worksheets = vec!["Sheet1".to_string()];
let mut parser = Parser::new(worksheets, vec![], HashMap::new());
let cell_reference = CellReferenceRC {
sheet: "Sheet1".to_string(),
row: 1,
column: 1,
};
let t = parser.parse("-(1 + 1)", &cell_reference);
assert_eq!(to_string(&t, &cell_reference), "-(1+1)");
let t = parser.parse("1 - (3 + 4)", &cell_reference);
assert_eq!(to_string(&t, &cell_reference), "1-(3+4)");
let t = parser.parse("-(1.05*(0.0284 + 0.0046) - 0.0284)", &cell_reference);
assert_eq!(
to_string(&t, &cell_reference),
"-(1.05*(0.0284+0.0046)-0.0284)"
);
let t = parser.parse("1 + (3+5)", &cell_reference);
assert_eq!(to_string(&t, &cell_reference), "1+3+5");
let t = parser.parse("1 - (3+5)", &cell_reference);
assert_eq!(to_string(&t, &cell_reference), "1-(3+5)");
let t = parser.parse("(1 - 3) - (3+5)", &cell_reference);
assert_eq!(to_string(&t, &cell_reference), "1-3-(3+5)");
let t = parser.parse("1 + (3<5)", &cell_reference);
assert_eq!(to_string(&t, &cell_reference), "1+(3<5)");
}

View File

@@ -211,15 +211,19 @@ pub fn parse_reference_a1(r: &str) -> Option<ParsedReference> {
pub fn is_valid_identifier(name: &str) -> bool { pub fn is_valid_identifier(name: &str) -> bool {
// https://support.microsoft.com/en-us/office/names-in-formulas-fc2935f9-115d-4bef-a370-3aa8bb4c91f1 // https://support.microsoft.com/en-us/office/names-in-formulas-fc2935f9-115d-4bef-a370-3aa8bb4c91f1
// https://github.com/MartinTrummer/excel-names/ // https://github.com/MartinTrummer/excel-names/
// NOTE: We are being much more restrictive than Excel.
// In particular we do not support non ascii characters.
let upper = name.to_ascii_uppercase(); let upper = name.to_ascii_uppercase();
let bytes = upper.as_bytes(); // length of chars
let len = bytes.len(); let len = upper.chars().count();
let mut chars = upper.chars();
if len > 255 || len == 0 { if len > 255 || len == 0 {
return false; return false;
} }
let first = bytes[0] as char; let first = match chars.next() {
Some(ch) => ch,
None => return false,
};
// The first character of a name must be a letter, an underscore character (_), or a backslash (\). // The first character of a name must be a letter, an underscore character (_), or a backslash (\).
if !(first.is_ascii_alphabetic() || first == '_' || first == '\\') { if !(first.is_ascii_alphabetic() || first == '_' || first == '\\') {
return false; return false;
@@ -237,20 +241,10 @@ pub fn is_valid_identifier(name: &str) -> bool {
if parse_reference_r1c1(name).is_some() { if parse_reference_r1c1(name).is_some() {
return false; return false;
} }
let mut i = 1; for ch in chars {
while i < len { if !(ch.is_alphanumeric() || ch == '_' || ch == '.') {
let ch = bytes[i] as char; return false;
match ch {
'a'..='z' => {}
'A'..='Z' => {}
'0'..='9' => {}
'_' => {}
'.' => {}
_ => {
return false;
}
} }
i += 1;
} }
true true
@@ -259,15 +253,23 @@ pub fn is_valid_identifier(name: &str) -> bool {
fn name_needs_quoting(name: &str) -> bool { fn name_needs_quoting(name: &str) -> bool {
let chars = name.chars(); let chars = name.chars();
// it contains any of these characters: ()'$,;-+{} or space // it contains any of these characters: ()'$,;-+{} or space
for char in chars { for (i, char) in chars.enumerate() {
if [' ', '(', ')', '\'', '$', ',', ';', '-', '+', '{', '}'].contains(&char) { if [' ', '(', ')', '\'', '$', ',', ';', '-', '+', '{', '}'].contains(&char) {
return true; return true;
} }
// if it starts with a number
if i == 0 && char.is_ascii_digit() {
return true;
}
}
if parse_reference_a1(name).is_some() {
// cell reference in A1 notation, e.g. B1048576 is quoted, B1048577 is not
return true;
}
if parse_reference_r1c1(name).is_some() {
// cell reference in R1C1 notation, e.g. RC, RC2, R5C, R-4C, RC-8, R, C
return true;
} }
// TODO:
// cell reference in A1 notation, e.g. B1048576 is quoted, B1048577 is not
// cell reference in R1C1 notation, e.g. RC, RC2, R5C, R-4C, RC-8, R, C
// integers
false false
} }
@@ -279,3 +281,32 @@ pub fn quote_name(name: &str) -> String {
}; };
name.to_string() name.to_string()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quote_name() {
assert_eq!(quote_name("Sheet1"), "Sheet1");
assert_eq!(quote_name("Sheet 1"), "'Sheet 1'");
// escape and quote
assert_eq!(quote_name("Sheet1'"), "'Sheet1'''");
assert_eq!(quote_name("Data(2024)"), "'Data(2024)'");
assert_eq!(quote_name("Data$2024"), "'Data$2024'");
assert_eq!(quote_name("Data-2024"), "'Data-2024'");
assert_eq!(quote_name("Data+2024"), "'Data+2024'");
assert_eq!(quote_name("Data,2024"), "'Data,2024'");
assert_eq!(quote_name("Data;2024"), "'Data;2024'");
assert_eq!(quote_name("Data{2024}"), "'Data{2024}'");
assert_eq!(quote_name("2024"), "'2024'");
assert_eq!(quote_name("1Data"), "'1Data'");
assert_eq!(quote_name("A1"), "'A1'");
assert_eq!(quote_name("R1C1"), "'R1C1'");
assert_eq!(quote_name("MySheet"), "MySheet");
assert_eq!(quote_name("B1048576"), "'B1048576'");
assert_eq!(quote_name("B1048577"), "B1048577");
}
}

View File

@@ -196,6 +196,7 @@ fn test_names() {
assert!(is_valid_identifier("_.")); assert!(is_valid_identifier("_."));
assert!(is_valid_identifier("_1")); assert!(is_valid_identifier("_1"));
assert!(is_valid_identifier("\\.")); assert!(is_valid_identifier("\\."));
assert!(is_valid_identifier("truñe"));
// invalid // invalid
assert!(!is_valid_identifier("true")); assert!(!is_valid_identifier("true"));
@@ -209,6 +210,7 @@ fn test_names() {
assert!(!is_valid_identifier("1true")); assert!(!is_valid_identifier("1true"));
assert!(!is_valid_identifier("test€")); assert!(!is_valid_identifier("test€"));
assert!(!is_valid_identifier("truñe"));
assert!(!is_valid_identifier("tr&ue")); assert!(!is_valid_identifier("tr&ue"));
assert!(!is_valid_identifier("LOG10"));
} }

View File

@@ -8,6 +8,8 @@ use crate::constants::EXCEL_DATE_BASE;
use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER; use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER;
use crate::constants::MINIMUM_DATE_SERIAL_NUMBER; use crate::constants::MINIMUM_DATE_SERIAL_NUMBER;
pub const DATE_OUT_OF_RANGE_MESSAGE: &str = "Out of range parameters for date";
#[inline] #[inline]
fn convert_to_serial_number(date: NaiveDate) -> i32 { fn convert_to_serial_number(date: NaiveDate) -> i32 {
date.num_days_from_ce() - EXCEL_DATE_BASE date.num_days_from_ce() - EXCEL_DATE_BASE
@@ -37,7 +39,7 @@ pub fn from_excel_date(days: i64) -> Result<NaiveDate, String> {
pub fn date_to_serial_number(day: u32, month: u32, year: i32) -> Result<i32, String> { pub fn date_to_serial_number(day: u32, month: u32, year: i32) -> Result<i32, String> {
match NaiveDate::from_ymd_opt(year, month, day) { match NaiveDate::from_ymd_opt(year, month, day) {
Some(native_date) => Ok(convert_to_serial_number(native_date)), Some(native_date) => Ok(convert_to_serial_number(native_date)),
None => Err("Out of range parameters for date".to_string()), None => Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()),
} }
} }
@@ -55,7 +57,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu
return Ok(MINIMUM_DATE_SERIAL_NUMBER); return Ok(MINIMUM_DATE_SERIAL_NUMBER);
} }
let Some(mut date) = NaiveDate::from_ymd_opt(year, 1, 1) else { let Some(mut date) = NaiveDate::from_ymd_opt(year, 1, 1) else {
return Err("Out of range parameters for date".to_string()); return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string());
}; };
// One thing to note for example is that even if you started with a year out of range // One thing to note for example is that even if you started with a year out of range
@@ -68,7 +70,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu
// As a result, we have to run range checks as we parse the date from the biggest unit to the // As a result, we have to run range checks as we parse the date from the biggest unit to the
// smallest unit. // smallest unit.
if !is_date_within_range(date) { if !is_date_within_range(date) {
return Err("Out of range parameters for date".to_string()); return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string());
} }
date = { date = {
@@ -80,7 +82,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu
date = date + Months::new(abs_month); date = date + Months::new(abs_month);
} }
if !is_date_within_range(date) { if !is_date_within_range(date) {
return Err("Out of range parameters for date".to_string()); return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string());
} }
date date
}; };
@@ -94,7 +96,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu
date = date + Days::new(abs_day); date = date + Days::new(abs_day);
} }
if !is_date_within_range(date) { if !is_date_within_range(date) {
return Err("Out of range parameters for date".to_string()); return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string());
} }
date date
}; };

View File

@@ -15,7 +15,7 @@ pub struct Formatted {
/// Returns the vector of chars of the fractional part of a *positive* number: /// Returns the vector of chars of the fractional part of a *positive* number:
/// 3.1415926 ==> ['1', '4', '1', '5', '9', '2', '6'] /// 3.1415926 ==> ['1', '4', '1', '5', '9', '2', '6']
fn get_fract_part(value: f64, precision: i32) -> Vec<char> { fn get_fract_part(value: f64, precision: i32, int_len: usize) -> Vec<char> {
let b = format!("{:.1$}", value.fract(), precision as usize) let b = format!("{:.1$}", value.fract(), precision as usize)
.chars() .chars()
.collect::<Vec<char>>(); .collect::<Vec<char>>();
@@ -30,6 +30,12 @@ fn get_fract_part(value: f64, precision: i32) -> Vec<char> {
if last_non_zero < 2 { if last_non_zero < 2 {
return vec![]; return vec![];
} }
let max_len = if int_len > 15 {
2_usize
} else {
15_usize - int_len + 1
};
let last_non_zero = usize::min(last_non_zero, max_len + 1);
b[2..last_non_zero].to_vec() b[2..last_non_zero].to_vec()
} }
@@ -154,16 +160,11 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
ParsePart::Date(p) => { ParsePart::Date(p) => {
let tokens = &p.tokens; let tokens = &p.tokens;
let mut text = "".to_string(); let mut text = "".to_string();
let date = match from_excel_date(value as i64) { let time_fract = value.fract();
Ok(d) => d, let hours = (time_fract * 24.0).floor();
Err(e) => { let minutes = ((time_fract * 24.0 - hours) * 60.0).floor();
return Formatted { let seconds = ((((time_fract * 24.0 - hours) * 60.0) - minutes) * 60.0).round();
text: "#VALUE!".to_owned(), let date = from_excel_date(value as i64).ok();
color: None,
error: Some(e),
}
}
};
for token in tokens { for token in tokens {
match token { match token {
TextToken::Literal(c) => { TextToken::Literal(c) => {
@@ -187,15 +188,44 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
} }
TextToken::Digit(_) => {} TextToken::Digit(_) => {}
TextToken::Period => {} TextToken::Period => {}
TextToken::Day => { TextToken::Day => match date {
let day = date.day() as usize; Some(date) => {
text = format!("{text}{day}"); let day = date.day() as usize;
} text = format!("{text}{day}");
}
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
},
TextToken::DayPadded => { TextToken::DayPadded => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
let day = date.day() as usize; let day = date.day() as usize;
text = format!("{text}{day:02}"); text = format!("{text}{day:02}");
} }
TextToken::DayNameShort => { TextToken::DayNameShort => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
let mut day = date.weekday().number_from_monday() as usize; let mut day = date.weekday().number_from_monday() as usize;
if day == 7 { if day == 7 {
day = 0; day = 0;
@@ -203,6 +233,16 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
text = format!("{}{}", text, &locale.dates.day_names_short[day]); text = format!("{}{}", text, &locale.dates.day_names_short[day]);
} }
TextToken::DayName => { TextToken::DayName => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
let mut day = date.weekday().number_from_monday() as usize; let mut day = date.weekday().number_from_monday() as usize;
if day == 7 { if day == 7 {
day = 0; day = 0;
@@ -210,32 +250,144 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
text = format!("{}{}", text, &locale.dates.day_names[day]); text = format!("{}{}", text, &locale.dates.day_names[day]);
} }
TextToken::Month => { TextToken::Month => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
let month = date.month() as usize; let month = date.month() as usize;
text = format!("{text}{month}"); text = format!("{text}{month}");
} }
TextToken::MonthPadded => { TextToken::MonthPadded => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
let month = date.month() as usize; let month = date.month() as usize;
text = format!("{text}{month:02}"); text = format!("{text}{month:02}");
} }
TextToken::MonthNameShort => { TextToken::MonthNameShort => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
let month = date.month() as usize; let month = date.month() as usize;
text = format!("{}{}", text, &locale.dates.months_short[month - 1]); text = format!("{}{}", text, &locale.dates.months_short[month - 1]);
} }
TextToken::MonthName => { TextToken::MonthName => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
let month = date.month() as usize; let month = date.month() as usize;
text = format!("{}{}", text, &locale.dates.months[month - 1]); text = format!("{}{}", text, &locale.dates.months[month - 1]);
} }
TextToken::MonthLetter => { TextToken::MonthLetter => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
let month = date.month() as usize; let month = date.month() as usize;
let months_letter = &locale.dates.months_letter[month - 1]; let months_letter = &locale.dates.months_letter[month - 1];
text = format!("{text}{months_letter}"); text = format!("{text}{months_letter}");
} }
TextToken::YearShort => { TextToken::YearShort => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
text = format!("{}{}", text, date.format("%y")); text = format!("{}{}", text, date.format("%y"));
} }
TextToken::Year => { TextToken::Year => {
let date = match date {
Some(d) => d,
None => {
return Formatted {
text: "#VALUE!".to_owned(),
color: None,
error: Some(format!("Invalid date value: '{value}'")),
}
}
};
text = format!("{}{}", text, date.year()); text = format!("{}{}", text, date.year());
} }
TextToken::Hour => {
let mut hour = hours as i32;
if p.use_ampm {
if hour == 0 {
hour = 12;
} else if hour > 12 {
hour -= 12;
}
}
text = format!("{text}{hour}");
}
TextToken::HourPadded => {
let mut hour = hours as i32;
if p.use_ampm {
if hour == 0 {
hour = 12;
} else if hour > 12 {
hour -= 12;
}
}
text = format!("{text}{hour:02}");
}
TextToken::Second => {
let second = seconds as i32;
text = format!("{text}{second}");
}
TextToken::SecondPadded => {
let second = seconds as i32;
text = format!("{text}{second:02}");
}
TextToken::AMPM => {
let ampm = if hours < 12.0 { "AM" } else { "PM" };
text = format!("{text}{ampm}");
}
TextToken::Minute => {
let minute = minutes as i32;
text = format!("{text}{minute}");
}
TextToken::MinutePadded => {
let minute = minutes as i32;
text = format!("{text}{minute:02}");
}
} }
} }
Formatted { Formatted {
@@ -277,7 +429,7 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
if value_abs as i64 == 0 { if value_abs as i64 == 0 {
int_part = vec![]; int_part = vec![];
} }
let fract_part = get_fract_part(value_abs, p.precision); let fract_part = get_fract_part(value_abs, p.precision, int_part.len());
// ln is the number of digits of the integer part of the value // ln is the number of digits of the integer part of the value
let ln = int_part.len() as i32; let ln = int_part.len() as i32;
// digit count is the number of digit tokens ('0', '?' and '#') to the left of the decimal point // digit count is the number of digit tokens ('0', '?' and '#') to the left of the decimal point
@@ -422,6 +574,13 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
TextToken::MonthLetter => {} TextToken::MonthLetter => {}
TextToken::YearShort => {} TextToken::YearShort => {}
TextToken::Year => {} TextToken::Year => {}
TextToken::Hour => {}
TextToken::HourPadded => {}
TextToken::Minute => {}
TextToken::MinutePadded => {}
TextToken::Second => {}
TextToken::SecondPadded => {}
TextToken::AMPM => {}
} }
} }
Formatted { Formatted {
@@ -591,10 +750,10 @@ fn parse_date(value: &str) -> Result<(i32, String), String> {
/// "30.34%" => (0.3034, "0.00%") /// "30.34%" => (0.3034, "0.00%")
/// 100€ => (100, "100€") /// 100€ => (100, "100€")
pub(crate) fn parse_formatted_number( pub(crate) fn parse_formatted_number(
value: &str, original: &str,
currencies: &[&str], currencies: &[&str],
) -> Result<(f64, Option<String>), String> { ) -> Result<(f64, Option<String>), String> {
let value = value.trim(); let value = original.trim();
let scientific_format = "0.00E+00"; let scientific_format = "0.00E+00";
// Check if it is a percentage // Check if it is a percentage
@@ -646,7 +805,8 @@ pub(crate) fn parse_formatted_number(
} }
} }
if let Ok((serial_number, format)) = parse_date(value) { // check if it is a date. NOTE: we don't trim the original here
if let Ok((serial_number, format)) = parse_date(original) {
return Ok((serial_number as f64, Some(format))); return Ok((serial_number as f64, Some(format)));
} }

View File

@@ -26,19 +26,23 @@ pub enum Token {
Scientific, // E+ Scientific, // E+
ScientificMinus, // E- ScientificMinus, // E-
General, // General General, // General
// Dates // Dates and time
Day, // d Day, // d
DayPadded, // dd DayPadded, // dd
DayNameShort, // ddd DayNameShort, // ddd
DayName, // dddd+ DayName, // dddd+
Month, // m Month, // m (or minute)
MonthPadded, // mm MonthPadded, // mm (or minute padded)
MonthNameShort, // mmm MonthNameShort, // mmm
MonthName, // mmmm or mmmmmm+ MonthName, // mmmm or mmmmmm+
MonthLetter, // mmmmm MonthLetter, // mmmmm
YearShort, // y or yy YearShort, // y or yy
Year, // yyy+ Year, // yyy+
// TODO: Hours Minutes and Seconds Hour, // h
HourPadded, // hh
Second, // s
SecondPadded, // ss
AMPM, // AM/PM (or A/P)
ILLEGAL, ILLEGAL,
EOF, EOF,
} }
@@ -361,8 +365,8 @@ impl Lexer {
self.read_next_char(); self.read_next_char();
} }
match m { match m {
1 => Token::Month, 1 => Token::Month, // (or minute)
2 => Token::MonthPadded, 2 => Token::MonthPadded, // (or minute padded)
3 => Token::MonthNameShort, 3 => Token::MonthNameShort,
4 => Token::MonthName, 4 => Token::MonthName,
5 => Token::MonthLetter, 5 => Token::MonthLetter,
@@ -381,6 +385,63 @@ impl Lexer {
Token::Year Token::Year
} }
} }
'h' => {
let mut h = 1;
while let Some('h') = self.peek_char() {
h += 1;
self.read_next_char();
}
if h == 1 {
Token::Hour
} else if h == 2 {
Token::HourPadded
} else {
self.set_error("Unexpected character after 'h'");
Token::ILLEGAL
}
}
's' => {
let mut s = 1;
while let Some('s') = self.peek_char() {
s += 1;
self.read_next_char();
}
if s == 1 {
Token::Second
} else if s == 2 {
Token::SecondPadded
} else {
self.set_error("Unexpected character after 's'");
Token::ILLEGAL
}
}
'A' | 'a' => {
if let Some('M') | Some('m') = self.peek_char() {
self.read_next_char();
} else {
self.set_error("Unexpected character after 'A'");
return Token::ILLEGAL;
}
if let Some('/') = self.peek_char() {
self.read_next_char();
} else {
self.set_error("Unexpected character after 'AM'");
return Token::ILLEGAL;
}
if let Some('P') | Some('p') = self.peek_char() {
self.read_next_char();
} else {
self.set_error("Unexpected character after 'AM'");
return Token::ILLEGAL;
}
if let Some('M') | Some('m') = self.peek_char() {
self.read_next_char();
} else {
self.set_error("Unexpected character after 'AMP'");
return Token::ILLEGAL;
}
Token::AMPM
}
'g' | 'G' => { 'g' | 'G' => {
for c in "eneral".chars() { for c in "eneral".chars() {
let cc = self.read_next_char(); let cc = self.read_next_char();

View File

@@ -27,6 +27,13 @@ pub enum TextToken {
MonthLetter, MonthLetter,
YearShort, YearShort,
Year, Year,
Hour,
HourPadded,
Minute,
MinutePadded,
Second,
SecondPadded,
AMPM,
} }
pub struct NumberPart { pub struct NumberPart {
pub color: Option<i32>, pub color: Option<i32>,
@@ -45,6 +52,7 @@ pub struct NumberPart {
pub struct DatePart { pub struct DatePart {
pub color: Option<i32>, pub color: Option<i32>,
pub use_ampm: bool,
pub tokens: Vec<TextToken>, pub tokens: Vec<TextToken>,
} }
@@ -101,6 +109,7 @@ impl Parser {
let mut digit_count = 0; let mut digit_count = 0;
let mut precision = 0; let mut precision = 0;
let mut is_date = false; let mut is_date = false;
let mut use_ampm = false;
let mut is_number = false; let mut is_number = false;
let mut found_decimal_dot = false; let mut found_decimal_dot = false;
let mut use_thousands = false; let mut use_thousands = false;
@@ -116,6 +125,7 @@ impl Parser {
let mut number = 'i'; let mut number = 'i';
let mut index = 0; let mut index = 0;
let mut currency = None; let mut currency = None;
let mut is_time = false;
while token != Token::EOF && token != Token::Separator { while token != Token::EOF && token != Token::Separator {
let next_token = self.lexer.next_token(); let next_token = self.lexer.next_token();
@@ -200,6 +210,9 @@ impl Parser {
index += 1; index += 1;
} }
Token::Literal(value) => { Token::Literal(value) => {
if value == ':' {
is_time = true;
}
tokens.push(TextToken::Literal(value)); tokens.push(TextToken::Literal(value));
} }
Token::Text(value) => { Token::Text(value) => {
@@ -236,12 +249,22 @@ impl Parser {
tokens.push(TextToken::MonthName); tokens.push(TextToken::MonthName);
} }
Token::Month => { Token::Month => {
is_date = true; if is_time {
tokens.push(TextToken::Month); // minute
tokens.push(TextToken::Minute);
} else {
is_date = true;
tokens.push(TextToken::Month);
}
} }
Token::MonthPadded => { Token::MonthPadded => {
is_date = true; if is_time {
tokens.push(TextToken::MonthPadded); // minute padded
tokens.push(TextToken::MinutePadded);
} else {
is_date = true;
tokens.push(TextToken::MonthPadded);
}
} }
Token::MonthLetter => { Token::MonthLetter => {
is_date = true; is_date = true;
@@ -255,6 +278,32 @@ impl Parser {
is_date = true; is_date = true;
tokens.push(TextToken::Year); tokens.push(TextToken::Year);
} }
Token::Hour => {
is_date = true;
is_time = true;
tokens.push(TextToken::Hour);
}
Token::HourPadded => {
is_date = true;
is_time = true;
tokens.push(TextToken::HourPadded);
}
Token::Second => {
is_date = true;
is_time = true;
tokens.push(TextToken::Second);
}
Token::SecondPadded => {
is_date = true;
is_time = true;
tokens.push(TextToken::SecondPadded);
}
Token::AMPM => {
is_date = true;
use_ampm = true;
tokens.push(TextToken::AMPM);
}
Token::Scientific => { Token::Scientific => {
if !is_scientific { if !is_scientific {
index = 0; index = 0;
@@ -282,7 +331,11 @@ impl Parser {
if is_number { if is_number {
return ParsePart::Error(ErrorPart {}); return ParsePart::Error(ErrorPart {});
} }
ParsePart::Date(DatePart { color, tokens }) ParsePart::Date(DatePart {
color,
use_ampm,
tokens,
})
} else { } else {
ParsePart::Number(NumberPart { ParsePart::Number(NumberPart {
color, color,

View File

@@ -1,2 +1,3 @@
mod test_general; mod test_general;
mod test_parse_formatted_number; mod test_parse_formatted_number;
mod test_time;

View File

@@ -0,0 +1,32 @@
#![allow(clippy::unwrap_used)]
use crate::{
formatter::format::format_number,
locale::{get_locale, Locale},
};
fn get_default_locale() -> &'static Locale {
get_locale("en").unwrap()
}
#[test]
fn simple_test() {
let locale = get_default_locale();
let format = "h:mm AM/PM";
let value = 16.001_423_611_111_11; // =1/86400 => 12:02 AM
let formatted = format_number(value, format, locale);
assert_eq!(formatted.text, "12:02 AM");
}
#[test]
fn padded_vs_unpadded() {
let locale = get_default_locale();
let padded_format = "hh:mm:ss AM/PM";
let unpadded_format = "h:m:s AM/PM";
let value = 0.25351851851851853; // => 6:05:04 AM (21904/(24*60*60)) where 21904 = 6 * 3600 + 5*60 + 4
let formatted = format_number(value, padded_format, locale);
assert_eq!(formatted.text, "06:05:04 AM");
let formatted = format_number(value, unpadded_format, locale);
assert_eq!(formatted.text, "6:5:4 AM");
}

View File

@@ -0,0 +1,946 @@
use chrono::Datelike;
use crate::{
calc_result::CalcResult,
expressions::{parser::Node, token::Error, types::CellReferenceIndex},
formatter::dates::date_to_serial_number,
Model,
};
use super::util::{compare_values, from_wildcard_to_regex, result_matches_regex};
impl Model {
// =DAVERAGE(database, field, criteria)
pub(crate) fn fn_daverage(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut sum = 0.0f64;
let mut count = 0usize;
let mut row = db_left.row + 1; // skip header
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(n) = v {
if n.is_finite() {
sum += n;
count += 1;
}
}
}
row += 1;
}
if count == 0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "No numeric values matched criteria".to_string(),
};
}
CalcResult::Number(sum / count as f64)
}
// =DCOUNT(database, field, criteria)
// Counts numeric entries in the field for rows that match criteria
pub(crate) fn fn_dcount(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut count = 0usize;
let mut row = db_left.row + 1; // skip header
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if matches!(v, CalcResult::Number(_)) {
count += 1;
}
}
row += 1;
}
CalcResult::Number(count as f64)
}
// =DGET(database, field, criteria)
// Returns the (single) field value for the unique matching row
pub(crate) fn fn_dget(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut result: Option<CalcResult> = None;
let mut matches = 0usize;
let mut row = db_left.row + 1;
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
matches += 1;
if matches > 1 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "More than one matching record".to_string(),
};
}
result = Some(self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
}));
}
row += 1;
}
match (matches, result) {
(0, _) | (_, None) => CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No matching record".to_string(),
},
(_, Some(v)) => v,
}
}
// =DMAX(database, field, criteria)
pub(crate) fn fn_dmax(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
self.db_extreme(args, cell, true)
}
// =DMIN(database, field, criteria)
pub(crate) fn fn_dmin(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
self.db_extreme(args, cell, false)
}
// =DSUM(database, field, criteria)
pub(crate) fn fn_dsum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut sum = 0.0;
// skip header
let mut row = db_left.row + 1;
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(n) = v {
if n.is_finite() {
sum += n;
}
}
}
row += 1;
}
CalcResult::Number(sum)
}
// =DCOUNTA(database, field, criteria)
// Counts non-empty entries (any type) in the field for rows that match criteria
pub(crate) fn fn_dcounta(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut count = 0;
for row in (db_left.row + 1)..=db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if !matches!(v, CalcResult::EmptyCell | CalcResult::EmptyArg) {
count += 1;
}
}
}
CalcResult::Number(count as f64)
}
// =DPRODUCT(database, field, criteria)
pub(crate) fn fn_dproduct(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut product = 1.0f64;
let mut has_numeric = false;
let mut row = db_left.row + 1; // skip header
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(n) = v {
if n.is_finite() {
product *= n;
has_numeric = true;
}
}
}
row += 1;
}
// Excel returns 0 when no rows / no numeric values match for DPRODUCT
if has_numeric {
CalcResult::Number(product)
} else {
CalcResult::Number(0.0)
}
}
// Small internal helper for DSTDEV / DVAR
// Collects sum, sum of squares, and count of numeric values in the field
// for rows that match the criteria.
fn db_numeric_stats(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> Result<(f64, f64, usize), CalcResult> {
if args.len() != 3 {
return Err(CalcResult::new_args_number_error(cell));
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return Err(e),
};
let field_col = self.resolve_db_field_column(db_left, db_right, &args[1], cell)?;
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return Err(e),
};
if db_right.row <= db_left.row {
// no data rows
return Err(CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
});
}
let mut sum = 0.0f64;
let mut sumsq = 0.0f64;
let mut count = 0usize;
let mut row = db_left.row + 1; // skip header
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(n) = v {
if n.is_finite() {
sum += n;
sumsq += n * n;
count += 1;
}
}
}
row += 1;
}
Ok((sum, sumsq, count))
}
// =DSTDEV(database, field, criteria)
// Sample standard deviation of matching numeric values
pub(crate) fn fn_dstdev(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let (sum, sumsq, count) = match self.db_numeric_stats(args, cell) {
Ok(stats) => stats,
Err(e) => return e,
};
// Excel behaviour: #DIV/0! if 0 or 1 numeric values match
if count < 2 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "Not enough numeric values matched criteria".to_string(),
};
}
let n = count as f64;
let var = (sumsq - (sum * sum) / n) / (n - 1.0);
let var = if var < 0.0 { 0.0 } else { var };
CalcResult::Number(var.sqrt())
}
// =DVAR(database, field, criteria)
// Sample variance of matching numeric values
pub(crate) fn fn_dvar(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let (sum, sumsq, count) = match self.db_numeric_stats(args, cell) {
Ok(stats) => stats,
Err(e) => return e,
};
// Excel behaviour: #DIV/0! if 0 or 1 numeric values match
if count < 2 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "Not enough numeric values matched criteria".to_string(),
};
}
let n = count as f64;
let var = (sumsq - (sum * sum) / n) / (n - 1.0);
let var = if var < 0.0 { 0.0 } else { var };
CalcResult::Number(var)
}
// =DSTDEVP(database, field, criteria)
// Population standard deviation of matching numeric values
pub(crate) fn fn_dstdevp(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let (sum, sumsq, count) = match self.db_numeric_stats(args, cell) {
Ok(stats) => stats,
Err(e) => return e,
};
// Excel behaviour: #DIV/0! if no numeric values match
if count == 0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "No numeric values matched criteria".to_string(),
};
}
let n = count as f64;
let var = (sumsq - (sum * sum) / n) / n;
let var = if var < 0.0 { 0.0 } else { var };
CalcResult::Number(var.sqrt())
}
// =DVARP(database, field, criteria)
// Population variance of matching numeric values
pub(crate) fn fn_dvarp(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let (sum, sumsq, count) = match self.db_numeric_stats(args, cell) {
Ok(stats) => stats,
Err(e) => return e,
};
// Excel behaviour: #DIV/0! if no numeric values match
if count == 0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "No numeric values matched criteria".to_string(),
};
}
let n = count as f64;
let var = (sumsq - (sum * sum) / n) / n;
let var = if var < 0.0 { 0.0 } else { var };
CalcResult::Number(var)
}
/// Resolve the "field" (2nd arg) to an absolute column index (i32) within the sheet.
/// Field can be a number (1-based index) or a header name (case-insensitive).
/// Returns the absolute column index, not a 1-based offset within the database range.
fn resolve_db_field_column(
&mut self,
db_left: CellReferenceIndex,
db_right: CellReferenceIndex,
field_arg: &Node,
cell: CellReferenceIndex,
) -> Result<i32, CalcResult> {
let field_column_name = match self.evaluate_node_in_context(field_arg, cell) {
CalcResult::String(s) => s.to_lowercase(),
CalcResult::Number(index) => {
let index = index.floor() as i32;
if index < 1 || db_left.column + index - 1 > db_right.column {
return Err(CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Field index out of range".to_string(),
});
}
return Ok(db_left.column + index - 1);
}
CalcResult::Boolean(b) => {
return if b {
Ok(db_left.column)
} else {
// Index 0 is out of range
Err(CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Invalid field specifier".to_string(),
})
};
}
error @ CalcResult::Error { .. } => {
return Err(error);
}
CalcResult::Range { .. } => {
return Err(CalcResult::Error {
error: Error::NIMPL,
origin: cell,
message: "Arrays not supported yet".to_string(),
})
}
CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(),
CalcResult::Array(_) => {
return Err(CalcResult::Error {
error: Error::NIMPL,
origin: cell,
message: "Arrays not supported yet".to_string(),
})
}
};
// We search in the database a column whose header matches field_column_name
for column in db_left.column..=db_right.column {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row: db_left.row,
column,
});
match &v {
CalcResult::String(s) => {
if s.to_lowercase() == field_column_name {
return Ok(column);
}
}
CalcResult::Number(n) => {
if field_column_name == n.to_string() {
return Ok(column);
}
}
CalcResult::Boolean(b) => {
if field_column_name == b.to_string() {
return Ok(column);
}
}
CalcResult::Error { .. }
| CalcResult::Range { .. }
| CalcResult::EmptyCell
| CalcResult::EmptyArg
| CalcResult::Array(_) => {}
}
}
Err(CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Field header not found".to_string(),
})
}
/// Check whether a database row matches the criteria range.
/// Criteria logic: OR across criteria rows; AND across columns within a row.
fn db_row_matches_criteria(
&mut self,
db_left: CellReferenceIndex,
db_right: CellReferenceIndex,
row: i32,
criteria: (CellReferenceIndex, CellReferenceIndex),
) -> bool {
let (c_left, c_right) = criteria;
// Read criteria headers (first row of criteria range)
// Map header name (lowercased) -> db column (if exists)
let mut crit_cols: Vec<i32> = Vec::new();
let mut header_count = 0;
// We cover the criteria table:
// headerA | headerB | ...
// critA1 | critA2 | ...
// critB1 | critB2 | ...
// ...
for column in c_left.column..=c_right.column {
let cell = CellReferenceIndex {
sheet: c_left.sheet,
row: c_left.row,
column,
};
let criteria_header = self.evaluate_cell(cell);
if let Ok(s) = self.cast_to_string(criteria_header, cell) {
// Non-empty string header. If the header is non string we skip it
header_count += 1;
let wanted = s.to_lowercase();
// Find corresponding Database column
let mut found = false;
for db_column in db_left.column..=db_right.column {
let db_header = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row: db_left.row,
column: db_column,
});
if let Ok(hs) = self.cast_to_string(db_header, cell) {
if hs.to_lowercase() == wanted {
crit_cols.push(db_column);
found = true;
break;
}
}
}
if !found {
// that means the criteria column has no matching DB column
// If the criteria condition is empty then we remove this condition
// otherwise this condition can never be satisfied
// We evaluate all criteria rows to see if any is non-empty
let mut has_non_empty = false;
for r in (c_left.row + 1)..=c_right.row {
let ccell = self.evaluate_cell(CellReferenceIndex {
sheet: c_left.sheet,
row: r,
column,
});
if !matches!(ccell, CalcResult::EmptyCell | CalcResult::EmptyArg) {
has_non_empty = true;
break;
}
}
if has_non_empty {
// This criteria column can never be satisfied
header_count -= 1;
}
}
};
}
if c_right.row <= c_left.row {
// If no criteria rows (only headers), everything matches
return true;
}
if header_count == 0 {
// If there are not "String" headers, nothing matches
// NB: There might be String headers that do not match any DB columns,
// in that case everything matches.
return false;
}
// Evaluate each criteria row (OR)
for r in (c_left.row + 1)..=c_right.row {
// AND across columns for this criteria row
let mut and_ok = true;
for (offset, db_col) in crit_cols.iter().enumerate() {
// Criteria cell
let ccell = self.evaluate_cell(CellReferenceIndex {
sheet: c_left.sheet,
row: r,
column: c_left.column + offset as i32,
});
// Empty criteria cell -> ignored
if matches!(ccell, CalcResult::EmptyCell | CalcResult::EmptyArg) {
continue;
}
// Database value for this row/column
let db_val = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: *db_col,
});
if !self.criteria_cell_matches(&db_val, &ccell) {
and_ok = false;
break;
}
}
if and_ok {
// This criteria row satisfied (OR)
return true;
}
}
// none matched
false
}
/// Implements Excel-like criteria matching for a single value.
/// Supports prefixes: <>, >=, <=, >, <, = ; wildcards * and ? for string equals.
fn criteria_cell_matches(&self, db_val: &CalcResult, crit_cell: &CalcResult) -> bool {
// Convert the criteria cell to a string for operator parsing if possible,
// otherwise fall back to equality via compare_values.
let mut criteria = match crit_cell {
CalcResult::String(s) => s.trim().to_string(),
CalcResult::Number(n) => {
// treat as equality with number
return match db_val {
CalcResult::Number(v) => (*v - *n).abs() <= f64::EPSILON,
_ => false,
};
}
CalcResult::Boolean(b) => {
// check equality with boolean
return match db_val {
CalcResult::Boolean(v) => *v == *b,
_ => false,
};
}
CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(),
CalcResult::Error { .. } => return false,
CalcResult::Range { .. } | CalcResult::Array(_) => return false,
};
// Detect operator prefix
let mut op = "="; // default equality (with wildcard semantics for strings)
let prefixes = ["<>", ">=", "<=", ">", "<", "="];
for p in prefixes.iter() {
if criteria.starts_with(p) {
op = p;
criteria = criteria[p.len()..].trim().to_string();
break;
}
}
// Is it a number?
let rhs_num = criteria.parse::<f64>().ok();
// Is it a date?
// FIXME: We should parse dates according to locale settings
let rhs_date = criteria.parse::<chrono::NaiveDate>().ok();
match op {
">" | ">=" | "<" | "<=" => {
if let Some(d) = rhs_date {
// date comparison
let serial = match date_to_serial_number(d.day(), d.month(), d.year()) {
Ok(sn) => sn as f64,
Err(_) => return false,
};
if let CalcResult::Number(n) = db_val {
match op {
">" => *n > serial,
">=" => *n >= serial,
"<" => *n < serial,
"<=" => *n <= serial,
_ => false,
}
} else {
false
}
} else if let Some(t) = rhs_num {
// numeric comparison
if let CalcResult::Number(n) = db_val {
match op {
">" => *n > t,
">=" => *n >= t,
"<" => *n < t,
"<=" => *n <= t,
_ => false,
}
} else {
false
}
} else {
// string comparison (case-insensitive) using compare_values semantics
let rhs = CalcResult::String(criteria.to_lowercase());
let lhs = match db_val {
CalcResult::String(s) => CalcResult::String(s.to_lowercase()),
x => x.clone(),
};
let c = compare_values(&lhs, &rhs);
match op {
">" => c > 0,
">=" => c >= 0,
"<" => c < 0,
"<=" => c <= 0,
_ => false,
}
}
}
"<>" => {
// not equal (with wildcard semantics for strings)
// If rhs has wildcards and db_val is string, do regex; else use compare_values != 0
if let CalcResult::String(s) = db_val {
if criteria.contains('*') || criteria.contains('?') {
if let Ok(re) = from_wildcard_to_regex(&criteria.to_lowercase(), true) {
return !result_matches_regex(
&CalcResult::String(s.to_lowercase()),
&re,
);
}
}
}
let rhs = if let Some(n) = rhs_num {
CalcResult::Number(n)
} else {
CalcResult::String(criteria.to_lowercase())
};
let lhs = match db_val {
CalcResult::String(s) => CalcResult::String(s.to_lowercase()),
x => x.clone(),
};
compare_values(&lhs, &rhs) != 0
}
_ => {
// equality. For strings, support wildcards (*, ?)
if let Some(n) = rhs_num {
// numeric equals
if let CalcResult::Number(m) = db_val {
(*m - n).abs() <= f64::EPSILON
} else {
compare_values(db_val, &CalcResult::Number(n)) == 0
}
} else {
// textual/boolean equals (case-insensitive), wildcard-enabled for strings
if let CalcResult::String(s) = db_val {
if criteria.contains('*') || criteria.contains('?') {
if let Ok(re) = from_wildcard_to_regex(&criteria.to_lowercase(), true) {
return result_matches_regex(
&CalcResult::String(s.to_lowercase()),
&re,
);
}
}
// This is weird but we only need to check if "starts with" for equality
return s.to_lowercase().starts_with(&criteria.to_lowercase());
}
// Fallback: compare_values equality
compare_values(db_val, &CalcResult::String(criteria.to_lowercase())) == 0
}
}
}
}
/// Shared implementation for DMAX/DMIN
fn db_extreme(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
want_max: bool,
) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut best: Option<f64> = None;
let mut row = db_left.row + 1;
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(value) = v {
if value.is_finite() {
best = Some(match best {
None => value,
Some(cur) => {
if want_max {
value.max(cur)
} else {
value.min(cur)
}
}
});
}
}
}
row += 1;
}
match best {
Some(v) => CalcResult::Number(v),
None => CalcResult::Number(0.0),
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
use statrs::function::erf::{erf, erfc};
use crate::{ use crate::{
calc_result::CalcResult, calc_result::CalcResult,
expressions::{parser::Node, token::Error, types::CellReferenceIndex}, expressions::{parser::Node, token::Error, types::CellReferenceIndex},
model::Model, model::Model,
}; };
use super::transcendental::{bessel_i, bessel_j, bessel_k, bessel_y, erf}; use super::transcendental::{bessel_i, bessel_j, bessel_k, bessel_y};
// https://root.cern/doc/v610/TMath_8cxx_source.html // https://root.cern/doc/v610/TMath_8cxx_source.html
// Notice that the parameters for Bessel functions in Excel and here have inverted order // Notice that the parameters for Bessel functions in Excel and here have inverted order
@@ -160,7 +162,7 @@ impl Model {
Ok(f) => f, Ok(f) => f,
Err(s) => return s, Err(s) => return s,
}; };
CalcResult::Number(1.0 - erf(x)) CalcResult::Number(erfc(x))
} }
pub(crate) fn fn_erfcprecise(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { pub(crate) fn fn_erfcprecise(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
@@ -171,6 +173,6 @@ impl Model {
Ok(f) => f, Ok(f) => f,
Err(s) => return s, Err(s) => return s,
}; };
CalcResult::Number(1.0 - erf(x)) CalcResult::Number(erfc(x))
} }
} }

View File

@@ -1,53 +0,0 @@
pub(crate) fn erf(x: f64) -> f64 {
let cof = vec![
-1.3026537197817094,
6.419_697_923_564_902e-1,
1.9476473204185836e-2,
-9.561_514_786_808_63e-3,
-9.46595344482036e-4,
3.66839497852761e-4,
4.2523324806907e-5,
-2.0278578112534e-5,
-1.624290004647e-6,
1.303655835580e-6,
1.5626441722e-8,
-8.5238095915e-8,
6.529054439e-9,
5.059343495e-9,
-9.91364156e-10,
-2.27365122e-10,
9.6467911e-11,
2.394038e-12,
-6.886027e-12,
8.94487e-13,
3.13092e-13,
-1.12708e-13,
3.81e-16,
7.106e-15,
-1.523e-15,
-9.4e-17,
1.21e-16,
-2.8e-17,
];
let mut d = 0.0;
let mut dd = 0.0;
let x_abs = x.abs();
let t = 2.0 / (2.0 + x_abs);
let ty = 4.0 * t - 2.0;
for j in (1..=cof.len() - 1).rev() {
let tmp = d;
d = ty * d - dd + cof[j];
dd = tmp;
}
let res = t * f64::exp(-x_abs * x_abs + 0.5 * (cof[0] + ty * d) - dd);
if x < 0.0 {
res - 1.0
} else {
1.0 - res
}
}

View File

@@ -4,7 +4,6 @@ mod bessel_j1_y1;
mod bessel_jn_yn; mod bessel_jn_yn;
mod bessel_k; mod bessel_k;
mod bessel_util; mod bessel_util;
mod erf;
#[cfg(test)] #[cfg(test)]
mod test_bessel; mod test_bessel;
@@ -13,4 +12,3 @@ pub(crate) use bessel_i::bessel_i;
pub(crate) use bessel_jn_yn::jn as bessel_j; pub(crate) use bessel_jn_yn::jn as bessel_j;
pub(crate) use bessel_jn_yn::yn as bessel_y; pub(crate) use bessel_jn_yn::yn as bessel_y;
pub(crate) use bessel_k::bessel_k; pub(crate) use bessel_k::bessel_k;
pub(crate) use erf::erf;

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
calc_result::CalcResult, calc_result::CalcResult,
expressions::{parser::Node, token::Error, types::CellReferenceIndex}, expressions::{parser::Node, token::Error, types::CellReferenceIndex, utils::number_to_column},
model::{Model, ParsedDefinedName}, model::{Model, ParsedDefinedName},
}; };
@@ -320,4 +320,150 @@ impl Model {
message: "Invalid name".to_string(), message: "Invalid name".to_string(),
} }
} }
pub(crate) fn fn_n(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let arg_count = args.len();
if arg_count != 1 {
return CalcResult::new_args_number_error(cell);
}
let value = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Number(n) => n,
CalcResult::String(_) => 0.0,
CalcResult::Boolean(f) => {
if f {
1.0
} else {
0.0
}
}
CalcResult::EmptyCell | CalcResult::EmptyArg => 0.0,
error @ CalcResult::Error { .. } => return error,
CalcResult::Range { .. } => {
return CalcResult::Error {
error: Error::NIMPL,
origin: cell,
message: "Arrays not supported yet".to_string(),
}
}
CalcResult::Array(_) => {
return CalcResult::Error {
error: Error::NIMPL,
origin: cell,
message: "Arrays not supported yet".to_string(),
}
}
};
CalcResult::Number(value)
}
pub(crate) fn fn_sheets(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let arg_count = args.len();
if arg_count > 1 {
return CalcResult::new_args_number_error(cell);
}
if arg_count == 1 {
return CalcResult::Error {
error: Error::NIMPL,
origin: cell,
message: "Sheets function with an argument is not implemented".to_string(),
};
}
let sheet_count = self.workbook.worksheets.len() as f64;
CalcResult::Number(sheet_count)
}
pub(crate) fn fn_cell(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let arg_count = args.len();
if arg_count == 0 || arg_count > 2 {
return CalcResult::new_args_number_error(cell);
}
let reference = if arg_count == 2 {
match self.evaluate_node_with_reference(&args[1], cell) {
CalcResult::Range { left, right: _ } => {
// we just take the left cell of the range
left
}
_ => {
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Argument must be a reference".to_string(),
}
}
}
} else {
CellReferenceIndex {
sheet: cell.sheet,
row: cell.row,
column: cell.column,
}
};
let info_type = match self.get_string(&args[0], cell) {
Ok(s) => s.to_uppercase(),
Err(e) => return e,
};
match info_type.as_str() {
"ADDRESS" => {
if reference.sheet != cell.sheet {
return CalcResult::Error {
error: Error::NIMPL,
origin: cell,
message: "References to other sheets not implemented".to_string(),
};
}
let column = match number_to_column(reference.column) {
Some(c) => c,
None => {
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Invalid column".to_string(),
}
}
};
let address = format!("${}${}", column, reference.row);
CalcResult::String(address)
}
"COL" => CalcResult::Number(reference.column as f64),
"COLOR" | "FILENAME" | "FORMAT" | "PARENTHESES" | "PREFIX" | "PROTECT" | "WIDTH" => {
CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "info_type not implemented".to_string(),
}
}
"CONTENTS" => self.evaluate_cell(reference),
"ROW" => CalcResult::Number(reference.row as f64),
"TYPE" => {
let cell_type = match self.evaluate_cell(reference) {
CalcResult::EmptyCell => "b",
CalcResult::String(_) => "l",
CalcResult::Number(_) => "v",
CalcResult::Boolean(_) => "v",
CalcResult::Error { .. } => "v",
CalcResult::Range { .. } => "v",
CalcResult::EmptyArg => "v",
CalcResult::Array(_) => "v",
};
CalcResult::String(cell_type.to_string())
}
_ => CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Invalid info_type".to_string(),
},
}
}
pub(crate) fn fn_info(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() || args.len() > 2 {
return CalcResult::new_args_number_error(cell);
}
CalcResult::Error {
error: Error::NIMPL,
origin: cell,
message: "Info function not implemented".to_string(),
}
}
} }

View File

@@ -246,7 +246,7 @@ impl Model {
} }
// None of the cases matched so we return the default // None of the cases matched so we return the default
// If there is an even number of args is the last one otherwise is #N/A // If there is an even number of args is the last one otherwise is #N/A
if args_count % 2 == 0 { if args_count.is_multiple_of(2) {
return self.evaluate_node_in_context(&args[args_count - 1], cell); return self.evaluate_node_in_context(&args[args_count - 1], cell);
} }
CalcResult::Error { CalcResult::Error {
@@ -262,7 +262,7 @@ impl Model {
if args_count < 2 { if args_count < 2 {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }
if args_count % 2 != 0 { if !args_count.is_multiple_of(2) {
// Missing value for last condition // Missing value for last condition
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }

View File

@@ -68,14 +68,14 @@ macro_rules! single_number_fn {
}, },
// If String, parse to f64 then apply or #VALUE! error // If String, parse to f64 then apply or #VALUE! error
ArrayNode::String(s) => { ArrayNode::String(s) => {
let node = match s.parse::<f64>() { let node = match self.cast_number(&s) {
Ok(f) => match $op(f) { Some(f) => match $op(f) {
Ok(x) => ArrayNode::Number(x), Ok(x) => ArrayNode::Number(x),
Err(Error::DIV) => ArrayNode::Error(Error::DIV), Err(Error::DIV) => ArrayNode::Error(Error::DIV),
Err(Error::VALUE) => ArrayNode::Error(Error::VALUE), Err(Error::VALUE) => ArrayNode::Error(Error::VALUE),
Err(e) => ArrayNode::Error(e), Err(e) => ArrayNode::Error(e),
}, },
Err(_) => ArrayNode::Error(Error::VALUE), None => ArrayNode::Error(Error::VALUE),
}; };
data_row.push(node); data_row.push(node);
} }

View File

@@ -0,0 +1,200 @@
/// Parse Roman (classic or Excel variants) → number
pub fn from_roman(s: &str) -> Result<u32, String> {
if s.is_empty() {
return Err("empty numeral".into());
}
fn val(c: char) -> Option<u32> {
Some(match c {
'I' => 1,
'V' => 5,
'X' => 10,
'L' => 50,
'C' => 100,
'D' => 500,
'M' => 1000,
_ => return None,
})
}
// Accept the union of subtractive pairs used by the tables above (Excel-compatible).
fn allowed_subtractive(a: char, b: char) -> bool {
matches!(
(a, b),
// classic:
('I','V')|('I','X')|('X','L')|('X','C')|('C','D')|('C','M')
// Excel forms:
|('V','L')|('L','D')|('L','M') // VL, LD, LM
|('X','D')|('X','M') // XD, XM
|('V','M') // VM
|('I','L')|('I','C')|('I','D')|('I','M') // IL, IC, ID, IM
|('V','D')|('V','C') // VD, VC
)
}
let chars: Vec<char> = s.chars().map(|c| c.to_ascii_uppercase()).collect();
let mut total = 0u32;
let mut i = 0usize;
// Repetition rules similar to classic Romans:
// V, L, D cannot repeat; I, X, C, M max 3 in a row.
let mut last_char: Option<char> = None;
let mut run_len = 0usize;
while i < chars.len() {
let c = chars[i];
let v = val(c).ok_or_else(|| format!("invalid character '{c}'"))?;
if Some(c) == last_char {
run_len += 1;
match c {
'V' | 'L' | 'D' => return Err(format!("invalid repetition of '{c}'")),
_ if run_len >= 3 => return Err(format!("invalid repetition of '{c}'")),
_ => {}
}
} else {
last_char = Some(c);
run_len = 0;
}
if i + 1 < chars.len() {
let c2 = chars[i + 1];
let v2 = val(c2).ok_or_else(|| format!("invalid character '{c2}'"))?;
if v < v2 {
if !allowed_subtractive(c, c2) {
return Err(format!("invalid subtractive pair '{c}{c2}'"));
}
// Disallow stacked subtractives like IIV, XXL:
if run_len > 0 {
return Err(format!("malformed numeral near position {i}"));
}
total += v2 - v;
i += 2;
last_char = None;
run_len = 0;
continue;
}
}
total += v;
i += 1;
}
Ok(total)
}
/// Classic Roman (strict) encoder used as a base for all forms.
fn to_roman(mut n: u32) -> Result<String, String> {
if !(1..=3999).contains(&n) {
return Err("value out of range (must be 1..=3999)".into());
}
const MAP: &[(u32, &str)] = &[
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
];
let mut out = String::with_capacity(15);
for &(val, sym) in MAP {
while n >= val {
out.push_str(sym);
n -= val;
}
if n == 0 {
break;
}
}
Ok(out)
}
/// Excel/Google Sheets compatible ROMAN(number, [form]) encoder.
/// `form`: 0..=4 (0=Classic, 4=Simplified).
pub fn to_roman_with_form(n: u32, form: i32) -> Result<String, String> {
let mut s = to_roman(n)?;
if form == 0 {
return Ok(s);
}
if !(0..=4).contains(&form) {
return Err("form must be between 0 and 4".into());
}
// Base rules (apply for all f >= 1)
let base_rules: &[(&str, &str)] = &[
// C(D|M)XC -> L$1XL
("CDXC", "LDXL"),
("CMXC", "LMXL"),
// C(D|M)L -> L$1
("CDL", "LD"),
("CML", "LM"),
// X(L|C)IX -> V$1IV
("XLIX", "VLIV"),
("XCIX", "VCIV"),
// X(L|C)V -> V$1
("XLV", "VL"),
("XCV", "VC"),
];
// Level 2 extra rules
let lvl2_rules: &[(&str, &str)] = &[
// V(L|C)IV -> I$1
("VLIV", "IL"),
("VCIV", "IC"),
// L(D|M)XL -> X$1
("LDXL", "XD"),
("LMXL", "XM"),
// L(D|M)VL -> X$1V
("LDVL", "XDV"),
("LMVL", "XMV"),
// L(D|M)IL -> X$1IX
("LDIL", "XDIX"),
("LMIL", "XMIX"),
];
// Level 3 extra rules
let lvl3_rules: &[(&str, &str)] = &[
// X(D|M)V -> V$1
("XDV", "VD"),
("XMV", "VM"),
// X(D|M)IX -> V$1IV
("XDIX", "VDIV"),
("XMIX", "VMIV"),
];
// Level 4 extra rules
let lvl4_rules: &[(&str, &str)] = &[
// V(D|M)IV -> I$1
("VDIV", "ID"),
("VMIV", "IM"),
];
// Helper to apply a batch of (from -> to) globally, in order.
fn apply_rules(mut t: String, rules: &[(&str, &str)]) -> String {
for (from, to) in rules {
if t.contains(from) {
t = t.replace(from, to);
}
}
t
}
s = apply_rules(s, base_rules);
if form >= 2 {
s = apply_rules(s, lvl2_rules);
}
if form >= 3 {
s = apply_rules(s, lvl3_rules);
}
if form >= 4 {
s = apply_rules(s, lvl4_rules);
}
Ok(s)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,213 @@
use statrs::distribution::{Beta, Continuous, ContinuousCDF};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
// BETA.DIST(x, alpha, beta, cumulative, [A], [B])
pub(crate) fn fn_beta_dist(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let arg_count = args.len();
if !(4..=6).contains(&arg_count) {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let alpha = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let beta_param = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
// cumulative argument: interpret like Excel
let cumulative = match self.evaluate_node_in_context(&args[3], cell) {
CalcResult::Boolean(b) => b,
CalcResult::Number(n) => n != 0.0,
CalcResult::String(s) => {
let up = s.to_ascii_uppercase();
if up == "TRUE" {
true
} else if up == "FALSE" {
false
} else {
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "cumulative must be TRUE/FALSE or numeric".to_string(),
};
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Invalid cumulative argument".to_string(),
}
}
};
// Optional A, B
let a = if arg_count >= 5 {
match self.get_number_no_bools(&args[4], cell) {
Ok(f) => f,
Err(e) => return e,
}
} else {
0.0
};
let b = if arg_count >= 6 {
match self.get_number_no_bools(&args[5], cell) {
Ok(f) => f,
Err(e) => return e,
}
} else {
1.0
};
// Excel: alpha <= 0 or beta <= 0 → #NUM!
if alpha <= 0.0 || beta_param <= 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"alpha and beta must be > 0 in BETA.DIST".to_string(),
);
}
// Excel: if x < A, x > B, or A = B → #NUM!
if b == a || x < a || x > b {
return CalcResult::new_error(
Error::NUM,
cell,
"x must be between A and B and A < B in BETA.DIST".to_string(),
);
}
// Transform to standard Beta(0,1)
let width = b - a;
let t = (x - a) / width;
let dist = match Beta::new(alpha, beta_param) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for Beta distribution".to_string(),
)
}
};
let result = if cumulative {
dist.cdf(t)
} else {
// general-interval beta pdf: f_X(x) = f_T(t) / (B - A), t=(x-A)/(B-A)
dist.pdf(t) / width
};
if result.is_nan() || result.is_infinite() {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for BETA.DIST".to_string(),
);
}
CalcResult::Number(result)
}
pub(crate) fn fn_beta_inv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let arg_count = args.len();
if !(3..=5).contains(&arg_count) {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let alpha = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let beta_param = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
let a = if arg_count >= 4 {
match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f,
Err(e) => return e,
}
} else {
0.0
};
let b = if arg_count >= 5 {
match self.get_number_no_bools(&args[4], cell) {
Ok(f) => f,
Err(e) => return e,
}
} else {
1.0
};
if alpha <= 0.0 || beta_param <= 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"alpha and beta must be > 0 in BETA.INV".to_string(),
);
}
// probability <= 0 or probability > 1 → #NUM!
if p <= 0.0 || p > 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"probability must be in (0,1] in BETA.INV".to_string(),
);
}
if b <= a {
return CalcResult::new_error(
Error::NUM,
cell,
"A must be < B in BETA.INV".to_string(),
);
}
let dist = match Beta::new(alpha, beta_param) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for Beta distribution".to_string(),
)
}
};
let t = dist.inverse_cdf(p);
if t.is_nan() || t.is_infinite() {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for BETA.INV".to_string(),
);
}
// Map back from [0,1] to [A,B]
let x = a + t * (b - a);
CalcResult::Number(x)
}
}

View File

@@ -0,0 +1,311 @@
use statrs::distribution::{Binomial, Discrete, DiscreteCDF};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
pub(crate) fn fn_binom_dist(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 4 {
return CalcResult::new_args_number_error(cell);
}
// number_s
let number_s = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// trials
let trials = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// probability_s
let p = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
// cumulative (logical)
let cumulative = match self.get_boolean(&args[3], cell) {
Ok(b) => b,
Err(e) => return e,
};
// Domain checks
if trials < 0.0
|| number_s < 0.0
|| number_s > trials
|| p.is_nan()
|| !(0.0..=1.0).contains(&p)
{
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for BINOM.DIST".to_string(),
);
}
// Limit to u64
if trials > u64::MAX as f64 {
return CalcResult::new_error(
Error::NUM,
cell,
"Number of trials too large".to_string(),
);
}
let n = trials as u64;
let k = number_s as u64;
let dist = match Binomial::new(p, n) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for binomial distribution".to_string(),
)
}
};
let prob = if cumulative { dist.cdf(k) } else { dist.pmf(k) };
if prob.is_nan() || prob.is_infinite() {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for BINOM.DIST".to_string(),
);
}
CalcResult::Number(prob)
}
pub(crate) fn fn_binom_dist_range(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() < 3 || args.len() > 4 {
return CalcResult::new_args_number_error(cell);
}
// trials
let trials = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// probability_s
let p = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
// number_s (lower)
let number_s = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// number_s2 (upper, optional)
let number_s2 = if args.len() == 4 {
match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
}
} else {
number_s
};
if trials < 0.0
|| number_s < 0.0
|| number_s2 < 0.0
|| number_s > number_s2
|| number_s2 > trials
|| p.is_nan()
|| !(0.0..=1.0).contains(&p)
{
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for BINOM.DIST.RANGE".to_string(),
);
}
if trials > u64::MAX as f64 {
return CalcResult::new_error(
Error::NUM,
cell,
"Number of trials too large".to_string(),
);
}
let n = trials as u64;
let lower = number_s as u64;
let upper = number_s2 as u64;
let dist = match Binomial::new(p, n) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for binomial distribution".to_string(),
)
}
};
let prob = if lower == 0 {
dist.cdf(upper)
} else {
let cdf_upper = dist.cdf(upper);
let cdf_below_lower = dist.cdf(lower - 1);
cdf_upper - cdf_below_lower
};
if prob.is_nan() || prob.is_infinite() {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for BINOM.DIST.RANGE".to_string(),
);
}
CalcResult::Number(prob)
}
pub(crate) fn fn_binom_inv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
// trials
let trials = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// probability_s
let p = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
// alpha
let alpha = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
if trials < 0.0
|| trials > u64::MAX as f64
|| p.is_nan()
|| !(0.0..=1.0).contains(&p)
|| alpha.is_nan()
|| !(0.0..=1.0).contains(&alpha)
{
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for BINOM.INV".to_string(),
);
}
let n = trials as u64;
let dist = match Binomial::new(p, n) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for binomial distribution".to_string(),
)
}
};
// DiscreteCDF::inverse_cdf returns u64 for binomial
let k = statrs::distribution::DiscreteCDF::inverse_cdf(&dist, alpha);
CalcResult::Number(k as f64)
}
pub(crate) fn fn_negbinom_dist(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
use statrs::distribution::{Discrete, DiscreteCDF, NegativeBinomial};
if args.len() != 4 {
return CalcResult::new_args_number_error(cell);
}
let number_f = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let number_s = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let probability_s = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[3], cell) {
Ok(b) => b,
Err(e) => return e,
};
if number_f < 0.0 || number_s < 1.0 || !(0.0..=1.0).contains(&probability_s) {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for NEGBINOM.DIST".to_string(),
};
}
// Guard against absurdly large failures that won't fit in u64
if number_f > (u64::MAX as f64) {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for NEGBINOM.DIST".to_string(),
};
}
let dist = match NegativeBinomial::new(number_s, probability_s) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for NEGBINOM.DIST".to_string(),
}
}
};
let f_u = number_f as u64;
let result = if cumulative {
dist.cdf(f_u)
} else {
dist.pmf(f_u)
};
if !result.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for NEGBINOM.DIST".to_string(),
};
}
CalcResult::Number(result)
}
}

View File

@@ -0,0 +1,565 @@
use statrs::distribution::{ChiSquared, Continuous, ContinuousCDF};
use crate::expressions::parser::ArrayNode;
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
// Helper to check if two shapes are the same or compatible 1D shapes
pub(crate) fn is_same_shape_or_1d(rows1: i32, cols1: i32, rows2: i32, cols2: i32) -> bool {
(rows1 == rows2 && cols1 == cols2)
|| (rows1 == 1 && cols2 == 1 && cols1 == rows2)
|| (rows2 == 1 && cols1 == 1 && cols2 == rows1)
}
impl Model {
// CHISQ.DIST(x, deg_freedom, cumulative)
pub(crate) fn fn_chisq_dist(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[2], cell) {
Ok(b) => b,
Err(e) => return e,
};
if x < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"x must be >= 0 in CHISQ.DIST".to_string(),
);
}
if df < 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"degrees of freedom must be >= 1 in CHISQ.DIST".to_string(),
);
}
let dist = match ChiSquared::new(df) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for Chi-squared distribution".to_string(),
)
}
};
let result = if cumulative { dist.cdf(x) } else { dist.pdf(x) };
if result.is_nan() || result.is_infinite() {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for CHISQ.DIST".to_string(),
);
}
CalcResult::Number(result)
}
// CHISQ.DIST.RT(x, deg_freedom)
pub(crate) fn fn_chisq_dist_rt(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df_raw = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df = df_raw.trunc();
if x < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"x must be >= 0 in CHISQ.DIST.RT".to_string(),
);
}
if df < 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"degrees of freedom must be >= 1 in CHISQ.DIST.RT".to_string(),
);
}
let dist = match ChiSquared::new(df) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for Chi-squared distribution".to_string(),
)
}
};
// Right-tail probability: P(X > x).
// Use sf(x) directly for better numerical properties than 1 - cdf(x).
let result = dist.sf(x);
if result.is_nan() || result.is_infinite() || result < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for CHISQ.DIST.RT".to_string(),
);
}
CalcResult::Number(result)
}
// CHISQ.INV(probability, deg_freedom)
pub(crate) fn fn_chisq_inv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// if probability < 0 or > 1 → #NUM!
if !(0.0..=1.0).contains(&p) {
return CalcResult::new_error(
Error::NUM,
cell,
"probability must be in [0,1] in CHISQ.INV".to_string(),
);
}
if df < 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"degrees of freedom must be >= 1 in CHISQ.INV".to_string(),
);
}
let dist = match ChiSquared::new(df) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for Chi-squared distribution".to_string(),
)
}
};
let x = dist.inverse_cdf(p);
if x.is_nan() || x.is_infinite() || x < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for CHISQ.INV".to_string(),
);
}
CalcResult::Number(x)
}
// CHISQ.INV.RT(probability, deg_freedom)
pub(crate) fn fn_chisq_inv_rt(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df_raw = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df = df_raw.trunc();
// if probability < 0 or > 1 → #NUM!
if !(0.0..=1.0).contains(&p) {
return CalcResult::new_error(
Error::NUM,
cell,
"probability must be in [0,1] in CHISQ.INV.RT".to_string(),
);
}
if df < 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"degrees of freedom must be >= 1 in CHISQ.INV.RT".to_string(),
);
}
let dist = match ChiSquared::new(df) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for Chi-squared distribution".to_string(),
)
}
};
// Right-tail inverse: p = P(X > x) = SF(x) = 1 - CDF(x)
// So x = inverse_cdf(1 - p).
let x = dist.inverse_cdf(1.0 - p);
if x.is_nan() || x.is_infinite() || x < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for CHISQ.INV.RT".to_string(),
);
}
CalcResult::Number(x)
}
pub(crate) fn values_from_range(
&mut self,
left: CellReferenceIndex,
right: CellReferenceIndex,
) -> Result<Vec<Option<f64>>, CalcResult> {
let mut values = Vec::new();
for row_offset in 0..=(right.row - left.row) {
for col_offset in 0..=(right.column - left.column) {
let cell_ref = CellReferenceIndex {
sheet: left.sheet,
row: left.row + row_offset,
column: left.column + col_offset,
};
let cell_value = self.evaluate_cell(cell_ref);
match cell_value {
CalcResult::Number(v) => {
values.push(Some(v));
}
error @ CalcResult::Error { .. } => return Err(error),
_ => {
values.push(None);
}
}
}
}
Ok(values)
}
pub(crate) fn values_from_array(
&mut self,
array: Vec<Vec<ArrayNode>>,
) -> Result<Vec<Option<f64>>, Error> {
let mut values = Vec::new();
for row in array {
for item in row {
match item {
ArrayNode::Number(f) => {
values.push(Some(f));
}
ArrayNode::Error(error) => {
return Err(error);
}
_ => {
values.push(None);
}
}
}
}
Ok(values)
}
// CHISQ.TEST(actual_range, expected_range)
pub(crate) fn fn_chisq_test(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let actual_range = self.evaluate_node_in_context(&args[0], cell);
let expected_range = self.evaluate_node_in_context(&args[1], cell);
let (width, height, values_left, values_right) = match (actual_range, expected_range) {
(
CalcResult::Range {
left: l1,
right: r1,
},
CalcResult::Range {
left: l2,
right: r2,
},
) => {
if l1.sheet != l2.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let rows1 = r1.row - l1.row + 1;
let cols1 = r1.column - l1.column + 1;
let rows2 = r2.row - l2.row + 1;
let cols2 = r2.column - l2.column + 1;
if !is_same_shape_or_1d(rows1, cols1, rows2, cols2) {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges must be of the same shape".to_string(),
);
}
let values_left = match self.values_from_range(l1, r1) {
Err(error) => {
return error;
}
Ok(v) => v,
};
let values_right = match self.values_from_range(l2, r2) {
Err(error) => {
return error;
}
Ok(v) => v,
};
(rows1, cols1, values_left, values_right)
}
(
CalcResult::Array(left),
CalcResult::Range {
left: l2,
right: r2,
},
) => {
let rows2 = r2.row - l2.row + 1;
let cols2 = r2.column - l2.column + 1;
let rows1 = left.len() as i32;
let cols1 = if rows1 > 0 { left[0].len() as i32 } else { 0 };
if !is_same_shape_or_1d(rows1, cols1, rows2, cols2) {
return CalcResult::new_error(
Error::VALUE,
cell,
"Array and range must be of the same shape".to_string(),
);
}
let values_left = match self.values_from_array(left) {
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in first array: {:?}", error),
);
}
Ok(v) => v,
};
let values_right = match self.values_from_range(l2, r2) {
Err(error) => {
return error;
}
Ok(v) => v,
};
(rows2, cols2, values_left, values_right)
}
(
CalcResult::Range {
left: l1,
right: r1,
},
CalcResult::Array(right),
) => {
let rows1 = r1.row - l1.row + 1;
let cols1 = r1.column - l1.column + 1;
let rows2 = right.len() as i32;
let cols2 = if rows2 > 0 { right[0].len() as i32 } else { 0 };
if !is_same_shape_or_1d(rows1, cols1, rows2, cols2) {
return CalcResult::new_error(
Error::VALUE,
cell,
"Range and array must be of the same shape".to_string(),
);
}
let values_left = match self.values_from_range(l1, r1) {
Err(error) => {
return error;
}
Ok(v) => v,
};
let values_right = match self.values_from_array(right) {
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in second array: {:?}", error),
);
}
Ok(v) => v,
};
(rows1, cols1, values_left, values_right)
}
(CalcResult::Array(left), CalcResult::Array(right)) => {
let rows1 = left.len() as i32;
let rows2 = right.len() as i32;
let cols1 = if rows1 > 0 { left[0].len() as i32 } else { 0 };
let cols2 = if rows2 > 0 { right[0].len() as i32 } else { 0 };
if !is_same_shape_or_1d(rows1, cols1, rows2, cols2) {
return CalcResult::new_error(
Error::VALUE,
cell,
"Arrays must be of the same shape".to_string(),
);
}
let values_left = match self.values_from_array(left) {
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in first array: {:?}", error),
);
}
Ok(v) => v,
};
let values_right = match self.values_from_array(right) {
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in second array: {:?}", error),
);
}
Ok(v) => v,
};
(rows1, cols1, values_left, values_right)
}
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"Both arguments must be ranges or arrays".to_string(),
);
}
};
let mut values = Vec::with_capacity(values_left.len());
// Now we have:
// - values: flattened (observed, expected)
// - width, height: shape
for i in 0..values_left.len() {
match (values_left[i], values_right[i]) {
(Some(v1), Some(v2)) => {
values.push((v1, v2));
}
_ => {
values.push((1.0, 1.0));
}
}
}
if width == 0 || height == 0 || values.len() < 2 {
return CalcResult::new_error(
Error::NUM,
cell,
"CHISQ.TEST requires at least two data points".to_string(),
);
}
let mut chi2 = 0.0;
for (obs, exp) in &values {
if *obs < 0.0 || *exp < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"Negative value in CHISQ.TEST data".to_string(),
);
}
if *exp == 0.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Zero expected value in CHISQ.TEST".to_string(),
);
}
let diff = obs - exp;
chi2 += (diff * diff) / exp;
}
if chi2 < 0.0 && chi2 > -1e-12 {
chi2 = 0.0;
}
let total = width * height;
if total <= 1 {
return CalcResult::new_error(
Error::NUM,
cell,
"CHISQ.TEST degrees of freedom is zero".to_string(),
);
}
let df = if width > 1 && height > 1 {
(width - 1) * (height - 1)
} else {
total - 1
};
let dist = match ChiSquared::new(df as f64) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid degrees of freedom in CHISQ.TEST".to_string(),
);
}
};
let mut p = 1.0 - dist.cdf(chi2);
// clamp tiny fp noise
if p < 0.0 && p > -1e-15 {
p = 0.0;
}
if p > 1.0 && p < 1.0 + 1e-15 {
p = 1.0;
}
CalcResult::Number(p)
}
}

View File

@@ -1,14 +1,10 @@
use crate::constants::{LAST_COLUMN, LAST_ROW}; use crate::constants::{LAST_COLUMN, LAST_ROW};
use crate::expressions::parser::ArrayNode;
use crate::expressions::types::CellReferenceIndex; use crate::expressions::types::CellReferenceIndex;
use crate::{ use crate::{
calc_result::{CalcResult, Range}, calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
expressions::parser::Node,
expressions::token::Error,
model::Model,
}; };
use super::util::build_criteria;
impl Model { impl Model {
pub(crate) fn fn_average(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { pub(crate) fn fn_average(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() { if args.is_empty() {
@@ -90,7 +86,6 @@ impl Model {
} }
CalcResult::Number(sum / count) CalcResult::Number(sum / count)
} }
pub(crate) fn fn_averagea(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { pub(crate) fn fn_averagea(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() { if args.is_empty() {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
@@ -324,350 +319,26 @@ impl Model {
CalcResult::Number(result) CalcResult::Number(result)
} }
pub(crate) fn fn_countif(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { pub(crate) fn fn_avedev(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() == 2 {
let arguments = vec![args[0].clone(), args[1].clone()];
self.fn_countifs(&arguments, cell)
} else {
CalcResult::new_args_number_error(cell)
}
}
/// AVERAGEIF(criteria_range, criteria, [average_range])
/// if average_rage is missing then criteria_range will be used
pub(crate) fn fn_averageif(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() == 2 {
let arguments = vec![args[0].clone(), args[0].clone(), args[1].clone()];
self.fn_averageifs(&arguments, cell)
} else if args.len() == 3 {
let arguments = vec![args[2].clone(), args[0].clone(), args[1].clone()];
self.fn_averageifs(&arguments, cell)
} else {
CalcResult::new_args_number_error(cell)
}
}
// FIXME: This function shares a lot of code with apply_ifs. Can we merge them?
pub(crate) fn fn_countifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let args_count = args.len();
if args_count < 2 || args_count % 2 == 1 {
return CalcResult::new_args_number_error(cell);
}
let case_count = args_count / 2;
// NB: this is a beautiful example of the borrow checker
// The order of these two definitions cannot be swapped.
let mut criteria = Vec::new();
let mut fn_criteria = Vec::new();
let ranges = &mut Vec::new();
for case_index in 0..case_count {
let criterion = self.evaluate_node_in_context(&args[case_index * 2 + 1], cell);
criteria.push(criterion);
// NB: We cannot do:
// fn_criteria.push(build_criteria(&criterion));
// because criterion doesn't live long enough
let result = self.evaluate_node_in_context(&args[case_index * 2], cell);
if result.is_error() {
return result;
}
if let CalcResult::Range { left, right } = result {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
// TODO test ranges are of the same size as sum_range
ranges.push(Range { left, right });
} else {
return CalcResult::new_error(Error::VALUE, cell, "Expected a range".to_string());
}
}
for criterion in criteria.iter() {
fn_criteria.push(build_criteria(criterion));
}
let mut total = 0.0;
let first_range = &ranges[0];
let left_row = first_range.left.row;
let left_column = first_range.left.column;
let right_row = first_range.right.row;
let right_column = first_range.right.column;
let dimension = match self.workbook.worksheet(first_range.left.sheet) {
Ok(s) => s.dimension(),
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", first_range.left.sheet),
)
}
};
let max_row = dimension.max_row;
let max_column = dimension.max_column;
let open_row = left_row == 1 && right_row == LAST_ROW;
let open_column = left_column == 1 && right_column == LAST_COLUMN;
for row in left_row..right_row + 1 {
if open_row && row > max_row {
// If the row is larger than the max row in the sheet then all cells are empty.
// We compute it only once
let mut is_true = true;
for fn_criterion in fn_criteria.iter() {
if !fn_criterion(&CalcResult::EmptyCell) {
is_true = false;
break;
}
}
if is_true {
total += ((LAST_ROW - max_row) * (right_column - left_column + 1)) as f64;
}
break;
}
for column in left_column..right_column + 1 {
if open_column && column > max_column {
// If the column is larger than the max column in the sheet then all cells are empty.
// We compute it only once
let mut is_true = true;
for fn_criterion in fn_criteria.iter() {
if !fn_criterion(&CalcResult::EmptyCell) {
is_true = false;
break;
}
}
if is_true {
total += (LAST_COLUMN - max_column) as f64;
}
break;
}
let mut is_true = true;
for case_index in 0..case_count {
// We check if value in range n meets criterion n
let range = &ranges[case_index];
let fn_criterion = &fn_criteria[case_index];
let value = self.evaluate_cell(CellReferenceIndex {
sheet: range.left.sheet,
row: range.left.row + row - first_range.left.row,
column: range.left.column + column - first_range.left.column,
});
if !fn_criterion(&value) {
is_true = false;
break;
}
}
if is_true {
total += 1.0;
}
}
}
CalcResult::Number(total)
}
pub(crate) fn apply_ifs<F>(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
mut apply: F,
) -> Result<(), CalcResult>
where
F: FnMut(f64),
{
let args_count = args.len();
if args_count < 3 || args_count % 2 == 0 {
return Err(CalcResult::new_args_number_error(cell));
}
let arg_0 = self.evaluate_node_in_context(&args[0], cell);
if arg_0.is_error() {
return Err(arg_0);
}
let sum_range = if let CalcResult::Range { left, right } = arg_0 {
if left.sheet != right.sheet {
return Err(CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
));
}
Range { left, right }
} else {
return Err(CalcResult::new_error(
Error::VALUE,
cell,
"Expected a range".to_string(),
));
};
let case_count = (args_count - 1) / 2;
// NB: this is a beautiful example of the borrow checker
// The order of these two definitions cannot be swapped.
let mut criteria = Vec::new();
let mut fn_criteria = Vec::new();
let ranges = &mut Vec::new();
for case_index in 1..=case_count {
let criterion = self.evaluate_node_in_context(&args[case_index * 2], cell);
// NB: criterion might be an error. That's ok
criteria.push(criterion);
// NB: We cannot do:
// fn_criteria.push(build_criteria(&criterion));
// because criterion doesn't live long enough
let result = self.evaluate_node_in_context(&args[case_index * 2 - 1], cell);
if result.is_error() {
return Err(result);
}
if let CalcResult::Range { left, right } = result {
if left.sheet != right.sheet {
return Err(CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
));
}
// TODO test ranges are of the same size as sum_range
ranges.push(Range { left, right });
} else {
return Err(CalcResult::new_error(
Error::VALUE,
cell,
"Expected a range".to_string(),
));
}
}
for criterion in criteria.iter() {
fn_criteria.push(build_criteria(criterion));
}
let left_row = sum_range.left.row;
let left_column = sum_range.left.column;
let mut right_row = sum_range.right.row;
let mut right_column = sum_range.right.column;
if left_row == 1 && right_row == LAST_ROW {
right_row = match self.workbook.worksheet(sum_range.left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return Err(CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", sum_range.left.sheet),
));
}
};
}
if left_column == 1 && right_column == LAST_COLUMN {
right_column = match self.workbook.worksheet(sum_range.left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return Err(CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", sum_range.left.sheet),
));
}
};
}
for row in left_row..right_row + 1 {
for column in left_column..right_column + 1 {
let mut is_true = true;
for case_index in 0..case_count {
// We check if value in range n meets criterion n
let range = &ranges[case_index];
let fn_criterion = &fn_criteria[case_index];
let value = self.evaluate_cell(CellReferenceIndex {
sheet: range.left.sheet,
row: range.left.row + row - sum_range.left.row,
column: range.left.column + column - sum_range.left.column,
});
if !fn_criterion(&value) {
is_true = false;
break;
}
}
if is_true {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: sum_range.left.sheet,
row,
column,
});
match v {
CalcResult::Number(n) => apply(n),
CalcResult::Error { .. } => return Err(v),
_ => {}
}
}
}
}
Ok(())
}
pub(crate) fn fn_averageifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let mut total = 0.0;
let mut count = 0.0;
let average = |value: f64| {
total += value;
count += 1.0;
};
if let Err(e) = self.apply_ifs(args, cell, average) {
return e;
}
if count == 0.0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "division by 0".to_string(),
};
}
CalcResult::Number(total / count)
}
pub(crate) fn fn_minifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let mut min = f64::INFINITY;
let apply_min = |value: f64| min = value.min(min);
if let Err(e) = self.apply_ifs(args, cell, apply_min) {
return e;
}
if min.is_infinite() {
min = 0.0;
}
CalcResult::Number(min)
}
pub(crate) fn fn_maxifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let mut max = -f64::INFINITY;
let apply_max = |value: f64| max = value.max(max);
if let Err(e) = self.apply_ifs(args, cell, apply_max) {
return e;
}
if max.is_infinite() {
max = 0.0;
}
CalcResult::Number(max)
}
pub(crate) fn fn_geomean(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() { if args.is_empty() {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }
let mut count = 0.0;
let mut product = 1.0; let mut values: Vec<f64> = Vec::new();
let mut sum = 0.0;
let mut count: u64 = 0;
#[inline]
fn accumulate(values: &mut Vec<f64>, sum: &mut f64, count: &mut u64, value: f64) {
values.push(value);
*sum += value;
*count += 1;
}
for arg in args { for arg in args {
match self.evaluate_node_in_context(arg, cell) { match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => { CalcResult::Number(value) => {
count += 1.0; accumulate(&mut values, &mut sum, &mut count, value);
product *= value;
}
CalcResult::Boolean(b) => {
if let Node::ReferenceKind { .. } = arg {
} else {
product *= if b { 1.0 } else { 0.0 };
count += 1.0;
}
} }
CalcResult::Range { left, right } => { CalcResult::Range { left, right } => {
if left.sheet != right.sheet { if left.sheet != right.sheet {
@@ -677,57 +348,99 @@ impl Model {
"Ranges are in different sheets".to_string(), "Ranges are in different sheets".to_string(),
); );
} }
for row in left.row..(right.row + 1) {
for column in left.column..(right.column + 1) { let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..=row2 {
for column in column1..=column2 {
match self.evaluate_cell(CellReferenceIndex { match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet, sheet: left.sheet,
row, row,
column, column,
}) { }) {
CalcResult::Number(value) => { CalcResult::Number(value) => {
count += 1.0; accumulate(&mut values, &mut sum, &mut count, value);
product *= value;
} }
error @ CalcResult::Error { .. } => return error, error @ CalcResult::Error { .. } => return error,
CalcResult::Range { .. } => { _ => {
return CalcResult::new_error( // ignore non-numeric
Error::ERROR, }
cell, }
"Unexpected Range".to_string(), }
); }
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut values, &mut sum, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// ignore non-numeric
} }
_ => {}
} }
} }
} }
} }
error @ CalcResult::Error { .. } => return error, error @ CalcResult::Error { .. } => return error,
CalcResult::String(s) => {
if let Node::ReferenceKind { .. } = arg {
// Do nothing
} else if let Ok(t) = s.parse::<f64>() {
product *= t;
count += 1.0;
} else {
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Argument cannot be cast into number".to_string(),
};
}
}
_ => { _ => {
// Ignore everything else // ignore non-numeric
} }
}; }
} }
if count == 0.0 {
return CalcResult::Error { if count == 0 {
error: Error::DIV, return CalcResult::new_error(
origin: cell, Error::DIV,
message: "Division by Zero".to_string(), cell,
}; "AVEDEV with no numeric data".to_string(),
);
} }
CalcResult::Number(product.powf(1.0 / count))
let n = count as f64;
let mean = sum / n;
let mut sum_abs_dev = 0.0;
for v in &values {
sum_abs_dev += (v - mean).abs();
}
CalcResult::Number(sum_abs_dev / n)
} }
} }

View File

@@ -0,0 +1,264 @@
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
pub(crate) fn fn_covariance_p(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let values1_opts = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Range { left, right } => match self.values_from_range(left, right) {
Ok(v) => v,
Err(error) => return error,
},
CalcResult::Array(a) => match self.values_from_array(a) {
Ok(v) => v,
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in first array: {:?}", error),
);
}
},
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"First argument must be a range or array".to_string(),
);
}
};
let values2_opts = match self.evaluate_node_in_context(&args[1], cell) {
CalcResult::Range { left, right } => match self.values_from_range(left, right) {
Ok(v) => v,
Err(error) => return error,
},
CalcResult::Array(a) => match self.values_from_array(a) {
Ok(v) => v,
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in second array: {:?}", error),
);
}
},
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"Second argument must be a range or array".to_string(),
);
}
};
// Same number of cells
if values1_opts.len() != values2_opts.len() {
return CalcResult::new_error(
Error::NA,
cell,
"COVARIANCE.P requires arrays of the same size".to_string(),
);
}
// Count numeric data points in each array (ignoring text/booleans/empty)
let count1 = values1_opts.iter().filter(|v| v.is_some()).count();
let count2 = values2_opts.iter().filter(|v| v.is_some()).count();
if count1 == 0 || count2 == 0 {
return CalcResult::new_error(
Error::DIV,
cell,
"COVARIANCE.P requires at least one numeric value in each array".to_string(),
);
}
if count1 != count2 {
return CalcResult::new_error(
Error::NA,
cell,
"COVARIANCE.P arrays must have the same number of numeric data points".to_string(),
);
}
// Build paired numeric vectors, position by position
let mut xs: Vec<f64> = Vec::with_capacity(count1);
let mut ys: Vec<f64> = Vec::with_capacity(count2);
for (v1_opt, v2_opt) in values1_opts.into_iter().zip(values2_opts.into_iter()) {
if let (Some(x), Some(y)) = (v1_opt, v2_opt) {
xs.push(x);
ys.push(y);
}
}
let n = xs.len();
if n == 0 {
// Should be impossible given the checks above, but guard anyway
return CalcResult::new_error(
Error::DIV,
cell,
"COVARIANCE.P has no paired numeric data points".to_string(),
);
}
let n_f = n as f64;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
for i in 0..n {
sum_x += xs[i];
sum_y += ys[i];
}
let mean_x = sum_x / n_f;
let mean_y = sum_y / n_f;
let mut sum_prod = 0.0;
for i in 0..n {
let dx = xs[i] - mean_x;
let dy = ys[i] - mean_y;
sum_prod += dx * dy;
}
let cov = sum_prod / n_f;
CalcResult::Number(cov)
}
pub(crate) fn fn_covariance_s(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let values1_opts = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Range { left, right } => match self.values_from_range(left, right) {
Ok(v) => v,
Err(error) => return error,
},
CalcResult::Array(a) => match self.values_from_array(a) {
Ok(v) => v,
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in first array: {:?}", error),
);
}
},
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"First argument must be a range or array".to_string(),
);
}
};
let values2_opts = match self.evaluate_node_in_context(&args[1], cell) {
CalcResult::Range { left, right } => match self.values_from_range(left, right) {
Ok(v) => v,
Err(error) => return error,
},
CalcResult::Array(a) => match self.values_from_array(a) {
Ok(v) => v,
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in second array: {:?}", error),
);
}
},
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"Second argument must be a range or array".to_string(),
);
}
};
// Same number of cells
if values1_opts.len() != values2_opts.len() {
return CalcResult::new_error(
Error::NA,
cell,
"COVARIANCE.S requires arrays of the same size".to_string(),
);
}
// Count numeric data points in each array (ignoring text/booleans/empty)
let count1 = values1_opts.iter().filter(|v| v.is_some()).count();
let count2 = values2_opts.iter().filter(|v| v.is_some()).count();
if count1 == 0 || count2 == 0 {
return CalcResult::new_error(
Error::DIV,
cell,
"COVARIANCE.S requires numeric values in each array".to_string(),
);
}
if count1 != count2 {
return CalcResult::new_error(
Error::NA,
cell,
"COVARIANCE.S arrays must have the same number of numeric data points".to_string(),
);
}
// Build paired numeric vectors
let mut xs: Vec<f64> = Vec::with_capacity(count1);
let mut ys: Vec<f64> = Vec::with_capacity(count2);
for (v1_opt, v2_opt) in values1_opts.into_iter().zip(values2_opts.into_iter()) {
if let (Some(x), Some(y)) = (v1_opt, v2_opt) {
xs.push(x);
ys.push(y);
}
}
let n = xs.len();
if n < 2 {
return CalcResult::new_error(
Error::DIV,
cell,
"COVARIANCE.S requires at least two paired data points".to_string(),
);
}
let n_f = n as f64;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
for i in 0..n {
sum_x += xs[i];
sum_y += ys[i];
}
let mean_x = sum_x / n_f;
let mean_y = sum_y / n_f;
let mut sum_prod = 0.0;
for i in 0..n {
let dx = xs[i] - mean_x;
let dy = ys[i] - mean_y;
sum_prod += dx * dy;
}
let cov = sum_prod / (n_f - 1.0);
CalcResult::Number(cov)
}
}

View File

@@ -0,0 +1,135 @@
use crate::constants::{LAST_COLUMN, LAST_ROW};
use crate::expressions::parser::ArrayNode;
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
// DEVSQ(number1, [number2], ...)
pub(crate) fn fn_devsq(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut sum = 0.0;
let mut sumsq = 0.0;
let mut count: u64 = 0;
// tiny helper so we don't repeat ourselves
#[inline]
fn accumulate(sum: &mut f64, sumsq: &mut f64, count: &mut u64, value: f64) {
*sum += value;
*sumsq += value * value;
*count += 1;
}
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..row2 + 1 {
for column in column1..(column2 + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
error @ CalcResult::Error { .. } => return error,
_ => {
// We ignore booleans and strings
}
}
}
}
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// We ignore booleans and strings
}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
// We ignore booleans and strings
}
};
}
if count == 0 {
// No numeric data at all
return CalcResult::new_error(
Error::DIV,
cell,
"DEVSQ with no numeric data".to_string(),
);
}
let n = count as f64;
let mut result = sumsq - (sum * sum) / n;
// Numerical noise can make result slightly negative when it should be 0
if result < 0.0 && result > -1e-12 {
result = 0.0;
}
CalcResult::Number(result)
}
}

View File

@@ -0,0 +1,54 @@
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
pub(crate) fn fn_expon_dist(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
// EXPON.DIST(x, lambda, cumulative)
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let lambda = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[2], cell) {
Ok(b) => b,
Err(e) => return e,
};
if x < 0.0 || lambda <= 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for EXPON.DIST".to_string(),
};
}
let result = if cumulative {
// CDF
1.0 - (-lambda * x).exp()
} else {
// PDF
lambda * (-lambda * x).exp()
};
if result.is_nan() || result.is_infinite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for EXPON.DIST".to_string(),
};
}
CalcResult::Number(result)
}
}

View File

@@ -0,0 +1,299 @@
use statrs::distribution::{Continuous, ContinuousCDF, FisherSnedecor};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
// FISHER(x) = 0.5 * ln((1 + x) / (1 - x))
pub(crate) fn fn_fisher(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 1 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
if x <= -1.0 || x >= 1.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "x must be between -1 and 1 (exclusive) in FISHER".to_string(),
};
}
let ratio = (1.0 + x) / (1.0 - x);
let result = 0.5 * ratio.ln();
if result.is_nan() || result.is_infinite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for FISHER".to_string(),
};
}
CalcResult::Number(result)
}
// FISHERINV(y) = (e^(2y) - 1) / (e^(2y) + 1) = tanh(y)
pub(crate) fn fn_fisher_inv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 1 {
return CalcResult::new_args_number_error(cell);
}
let y = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
// Use tanh directly to avoid overflow from exp(2y)
let result = y.tanh();
if result.is_nan() || result.is_infinite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for FISHERINV".to_string(),
};
}
CalcResult::Number(result)
}
// F.DIST(x, deg_freedom1, deg_freedom2, cumulative)
pub(crate) fn fn_f_dist(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 4 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df1 = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let df2 = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[3], cell) {
Ok(b) => b,
Err(e) => return e,
};
// Excel domain checks
if x < 0.0 {
return CalcResult::new_error(Error::NUM, cell, "x must be >= 0 in F.DIST".to_string());
}
if df1 < 1.0 || df2 < 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"degrees of freedom must be >= 1 in F.DIST".to_string(),
);
}
let dist = match FisherSnedecor::new(df1, df2) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for F distribution".to_string(),
)
}
};
let result = if cumulative { dist.cdf(x) } else { dist.pdf(x) };
if result.is_nan() || result.is_infinite() {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for F.DIST".to_string(),
);
}
CalcResult::Number(result)
}
pub(crate) fn fn_f_dist_rt(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
// F.DIST.RT(x, deg_freedom1, deg_freedom2)
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df1 = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let df2 = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
if x < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"x must be >= 0 in F.DIST.RT".to_string(),
);
}
if df1 < 1.0 || df2 < 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"degrees of freedom must be >= 1 in F.DIST.RT".to_string(),
);
}
let dist = match FisherSnedecor::new(df1, df2) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for F distribution".to_string(),
)
}
};
// Right-tail probability: P(F > x) = 1 - CDF(x)
let result = 1.0 - dist.cdf(x);
if result.is_nan() || result.is_infinite() || result < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for F.DIST.RT".to_string(),
);
}
CalcResult::Number(result)
}
// F.INV(probability, deg_freedom1, deg_freedom2)
pub(crate) fn fn_f_inv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df1 = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let df2 = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// probability < 0 or > 1 → #NUM!
if !(0.0..=1.0).contains(&p) {
return CalcResult::new_error(
Error::NUM,
cell,
"probability must be in [0,1] in F.INV".to_string(),
);
}
if df1 < 1.0 || df2 < 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"degrees of freedom must be >= 1 in F.INV".to_string(),
);
}
let dist = match FisherSnedecor::new(df1, df2) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for F distribution".to_string(),
)
}
};
let x = dist.inverse_cdf(p);
if x.is_nan() || x.is_infinite() || x < 0.0 {
return CalcResult::new_error(Error::NUM, cell, "Invalid result for F.INV".to_string());
}
CalcResult::Number(x)
}
// F.INV.RT(probability, deg_freedom1, deg_freedom2)
pub(crate) fn fn_f_inv_rt(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df1 = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let df2 = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
if p <= 0.0 || p > 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"probability must be in (0,1] in F.INV.RT".to_string(),
);
}
if df1 < 1.0 || df2 < 1.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"degrees of freedom must be >= 1 in F.INV.RT".to_string(),
);
}
let dist = match FisherSnedecor::new(df1, df2) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for F distribution".to_string(),
)
}
};
// p is right-tail: p = P(F > x) = 1 - CDF(x)
let x = dist.inverse_cdf(1.0 - p);
if x.is_nan() || x.is_infinite() || x < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for F.INV.RT".to_string(),
);
}
CalcResult::Number(x)
}
}

View File

@@ -0,0 +1,194 @@
use statrs::distribution::{Continuous, ContinuousCDF, Gamma};
use statrs::function::gamma::{gamma, ln_gamma};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
pub(crate) fn fn_gamma(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 1 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(s) => return s,
};
if x < 0.0 && x.floor() == x {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for Gamma function".to_string(),
};
}
let result = gamma(x);
if result.is_nan() || result.is_infinite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for Gamma function".to_string(),
};
}
CalcResult::Number(result)
}
pub(crate) fn fn_gamma_dist(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
// GAMMA.DIST(x, alpha, beta, cumulative)
if args.len() != 4 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let alpha = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let beta_scale = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[3], cell) {
Ok(b) => b,
Err(e) => return e,
};
if x < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"x must be >= 0 in GAMMA.DIST".to_string(),
);
}
if alpha <= 0.0 || beta_scale <= 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"alpha and beta must be > 0 in GAMMA.DIST".to_string(),
);
}
let rate = 1.0 / beta_scale;
let dist = match Gamma::new(alpha, rate) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for Gamma distribution".to_string(),
)
}
};
let result = if cumulative { dist.cdf(x) } else { dist.pdf(x) };
if result.is_nan() || result.is_infinite() {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for GAMMA.DIST".to_string(),
);
}
CalcResult::Number(result)
}
pub(crate) fn fn_gamma_inv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
// GAMMA.INV(probability, alpha, beta)
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let alpha = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let beta_scale = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
if !(0.0..=1.0).contains(&p) {
return CalcResult::new_error(
Error::NUM,
cell,
"probability must be in [0,1] in GAMMA.INV".to_string(),
);
}
if alpha <= 0.0 || beta_scale <= 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"alpha and beta must be > 0 in GAMMA.INV".to_string(),
);
}
let rate = 1.0 / beta_scale;
let dist = match Gamma::new(alpha, rate) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for Gamma distribution".to_string(),
)
}
};
let x = dist.inverse_cdf(p);
if x.is_nan() || x.is_infinite() || x < 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid result for GAMMA.INV".to_string(),
);
}
CalcResult::Number(x)
}
pub(crate) fn fn_gamma_ln(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 1 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(s) => return s,
};
if x < 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for Gamma function".to_string(),
};
}
let result = ln_gamma(x);
if result.is_nan() || result.is_infinite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for Gamma Ln function".to_string(),
};
}
CalcResult::Number(result)
}
pub(crate) fn fn_gamma_ln_precise(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
self.fn_gamma_ln(args, cell)
}
}

View File

@@ -0,0 +1,87 @@
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
pub(crate) fn fn_geomean(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut count = 0.0;
let mut product = 1.0;
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
count += 1.0;
product *= value;
}
CalcResult::Boolean(b) => {
if let Node::ReferenceKind { .. } = arg {
} else {
product *= if b { 1.0 } else { 0.0 };
count += 1.0;
}
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
for row in left.row..(right.row + 1) {
for column in left.column..(right.column + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
count += 1.0;
product *= value;
}
error @ CalcResult::Error { .. } => return error,
CalcResult::Range { .. } => {
return CalcResult::new_error(
Error::ERROR,
cell,
"Unexpected Range".to_string(),
);
}
_ => {}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
CalcResult::String(s) => {
if let Node::ReferenceKind { .. } = arg {
// Do nothing
} else if let Ok(t) = s.parse::<f64>() {
product *= t;
count += 1.0;
} else {
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Argument cannot be cast into number".to_string(),
};
}
}
_ => {
// Ignore everything else
}
};
}
if count == 0.0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "Division by Zero".to_string(),
};
}
CalcResult::Number(product.powf(1.0 / count))
}
}

View File

@@ -0,0 +1,108 @@
use statrs::distribution::{Discrete, DiscreteCDF, Hypergeometric};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
// =HYPGEOM.DIST(sample_s, number_sample, population_s, number_pop, cumulative)
pub(crate) fn fn_hyp_geom_dist(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 5 {
return CalcResult::new_args_number_error(cell);
}
// sample_s (number of successes in the sample)
let sample_s = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// number_sample (sample size)
let number_sample = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// population_s (number of successes in the population)
let population_s = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// number_pop (population size)
let number_pop = match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[4], cell) {
Ok(b) => b,
Err(e) => return e,
};
if sample_s < 0.0 || sample_s > f64::min(number_sample, population_s) {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for HYPGEOM.DIST".to_string(),
};
}
if sample_s < f64::max(0.0, number_sample + population_s - number_pop) {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for HYPGEOM.DIST".to_string(),
};
}
if number_sample <= 0.0 || number_sample > number_pop {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for HYPGEOM.DIST".to_string(),
};
}
if population_s <= 0.0 || population_s > number_pop {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for HYPGEOM.DIST".to_string(),
};
}
let n_pop = number_pop as u64;
let k_pop = population_s as u64;
let n_sample = number_sample as u64;
let k = sample_s as u64;
let dist = match Hypergeometric::new(n_pop, k_pop, n_sample) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for hypergeometric distribution".to_string(),
)
}
};
let prob = if cumulative { dist.cdf(k) } else { dist.pmf(k) };
if !prob.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for HYPGEOM.DIST".to_string(),
};
}
CalcResult::Number(prob)
}
}

View File

@@ -0,0 +1,337 @@
use crate::constants::{LAST_COLUMN, LAST_ROW};
use crate::expressions::types::CellReferenceIndex;
use crate::functions::util::build_criteria;
use crate::{
calc_result::{CalcResult, Range},
expressions::parser::Node,
expressions::token::Error,
model::Model,
};
impl Model {
pub(crate) fn fn_countif(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() == 2 {
let arguments = vec![args[0].clone(), args[1].clone()];
self.fn_countifs(&arguments, cell)
} else {
CalcResult::new_args_number_error(cell)
}
}
/// AVERAGEIF(criteria_range, criteria, [average_range])
/// if average_rage is missing then criteria_range will be used
pub(crate) fn fn_averageif(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() == 2 {
let arguments = vec![args[0].clone(), args[0].clone(), args[1].clone()];
self.fn_averageifs(&arguments, cell)
} else if args.len() == 3 {
let arguments = vec![args[2].clone(), args[0].clone(), args[1].clone()];
self.fn_averageifs(&arguments, cell)
} else {
CalcResult::new_args_number_error(cell)
}
}
// FIXME: This function shares a lot of code with apply_ifs. Can we merge them?
pub(crate) fn fn_countifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let args_count = args.len();
if args_count < 2 || !args_count.is_multiple_of(2) {
return CalcResult::new_args_number_error(cell);
}
let case_count = args_count / 2;
// NB: this is a beautiful example of the borrow checker
// The order of these two definitions cannot be swapped.
let mut criteria = Vec::new();
let mut fn_criteria = Vec::new();
let ranges = &mut Vec::new();
for case_index in 0..case_count {
let criterion = self.evaluate_node_in_context(&args[case_index * 2 + 1], cell);
criteria.push(criterion);
// NB: We cannot do:
// fn_criteria.push(build_criteria(&criterion));
// because criterion doesn't live long enough
let result = self.evaluate_node_in_context(&args[case_index * 2], cell);
if result.is_error() {
return result;
}
if let CalcResult::Range { left, right } = result {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
// TODO test ranges are of the same size as sum_range
ranges.push(Range { left, right });
} else {
return CalcResult::new_error(Error::VALUE, cell, "Expected a range".to_string());
}
}
for criterion in criteria.iter() {
fn_criteria.push(build_criteria(criterion));
}
let mut total = 0.0;
let first_range = &ranges[0];
let left_row = first_range.left.row;
let left_column = first_range.left.column;
let right_row = first_range.right.row;
let right_column = first_range.right.column;
let dimension = match self.workbook.worksheet(first_range.left.sheet) {
Ok(s) => s.dimension(),
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", first_range.left.sheet),
)
}
};
let max_row = dimension.max_row;
let max_column = dimension.max_column;
let open_row = left_row == 1 && right_row == LAST_ROW;
let open_column = left_column == 1 && right_column == LAST_COLUMN;
for row in left_row..right_row + 1 {
if open_row && row > max_row {
// If the row is larger than the max row in the sheet then all cells are empty.
// We compute it only once
let mut is_true = true;
for fn_criterion in fn_criteria.iter() {
if !fn_criterion(&CalcResult::EmptyCell) {
is_true = false;
break;
}
}
if is_true {
total += ((LAST_ROW - max_row) * (right_column - left_column + 1)) as f64;
}
break;
}
for column in left_column..right_column + 1 {
if open_column && column > max_column {
// If the column is larger than the max column in the sheet then all cells are empty.
// We compute it only once
let mut is_true = true;
for fn_criterion in fn_criteria.iter() {
if !fn_criterion(&CalcResult::EmptyCell) {
is_true = false;
break;
}
}
if is_true {
total += (LAST_COLUMN - max_column) as f64;
}
break;
}
let mut is_true = true;
for case_index in 0..case_count {
// We check if value in range n meets criterion n
let range = &ranges[case_index];
let fn_criterion = &fn_criteria[case_index];
let value = self.evaluate_cell(CellReferenceIndex {
sheet: range.left.sheet,
row: range.left.row + row - first_range.left.row,
column: range.left.column + column - first_range.left.column,
});
if !fn_criterion(&value) {
is_true = false;
break;
}
}
if is_true {
total += 1.0;
}
}
}
CalcResult::Number(total)
}
pub(crate) fn apply_ifs<F>(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
mut apply: F,
) -> Result<(), CalcResult>
where
F: FnMut(f64),
{
let args_count = args.len();
if args_count < 3 || args_count.is_multiple_of(2) {
return Err(CalcResult::new_args_number_error(cell));
}
let arg_0 = self.evaluate_node_in_context(&args[0], cell);
if arg_0.is_error() {
return Err(arg_0);
}
let sum_range = if let CalcResult::Range { left, right } = arg_0 {
if left.sheet != right.sheet {
return Err(CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
));
}
Range { left, right }
} else {
return Err(CalcResult::new_error(
Error::VALUE,
cell,
"Expected a range".to_string(),
));
};
let case_count = (args_count - 1) / 2;
// NB: this is a beautiful example of the borrow checker
// The order of these two definitions cannot be swapped.
let mut criteria = Vec::new();
let mut fn_criteria = Vec::new();
let ranges = &mut Vec::new();
for case_index in 1..=case_count {
let criterion = self.evaluate_node_in_context(&args[case_index * 2], cell);
// NB: criterion might be an error. That's ok
criteria.push(criterion);
// NB: We cannot do:
// fn_criteria.push(build_criteria(&criterion));
// because criterion doesn't live long enough
let result = self.evaluate_node_in_context(&args[case_index * 2 - 1], cell);
if result.is_error() {
return Err(result);
}
if let CalcResult::Range { left, right } = result {
if left.sheet != right.sheet {
return Err(CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
));
}
// TODO test ranges are of the same size as sum_range
ranges.push(Range { left, right });
} else {
return Err(CalcResult::new_error(
Error::VALUE,
cell,
"Expected a range".to_string(),
));
}
}
for criterion in criteria.iter() {
fn_criteria.push(build_criteria(criterion));
}
let left_row = sum_range.left.row;
let left_column = sum_range.left.column;
let mut right_row = sum_range.right.row;
let mut right_column = sum_range.right.column;
if left_row == 1 && right_row == LAST_ROW {
right_row = match self.workbook.worksheet(sum_range.left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return Err(CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", sum_range.left.sheet),
));
}
};
}
if left_column == 1 && right_column == LAST_COLUMN {
right_column = match self.workbook.worksheet(sum_range.left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return Err(CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", sum_range.left.sheet),
));
}
};
}
for row in left_row..right_row + 1 {
for column in left_column..right_column + 1 {
let mut is_true = true;
for case_index in 0..case_count {
// We check if value in range n meets criterion n
let range = &ranges[case_index];
let fn_criterion = &fn_criteria[case_index];
let value = self.evaluate_cell(CellReferenceIndex {
sheet: range.left.sheet,
row: range.left.row + row - sum_range.left.row,
column: range.left.column + column - sum_range.left.column,
});
if !fn_criterion(&value) {
is_true = false;
break;
}
}
if is_true {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: sum_range.left.sheet,
row,
column,
});
match v {
CalcResult::Number(n) => apply(n),
CalcResult::Error { .. } => return Err(v),
_ => {}
}
}
}
}
Ok(())
}
pub(crate) fn fn_averageifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let mut total = 0.0;
let mut count = 0.0;
let average = |value: f64| {
total += value;
count += 1.0;
};
if let Err(e) = self.apply_ifs(args, cell, average) {
return e;
}
if count == 0.0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "division by 0".to_string(),
};
}
CalcResult::Number(total / count)
}
pub(crate) fn fn_minifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let mut min = f64::INFINITY;
let apply_min = |value: f64| min = value.min(min);
if let Err(e) = self.apply_ifs(args, cell, apply_min) {
return e;
}
if min.is_infinite() {
min = 0.0;
}
CalcResult::Number(min)
}
pub(crate) fn fn_maxifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let mut max = -f64::INFINITY;
let apply_max = |value: f64| max = value.max(max);
if let Err(e) = self.apply_ifs(args, cell, apply_max) {
return e;
}
if max.is_infinite() {
max = 0.0;
}
CalcResult::Number(max)
}
}

View File

@@ -0,0 +1,124 @@
use statrs::distribution::{Continuous, ContinuousCDF, LogNormal};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
pub(crate) fn fn_log_norm_dist(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 4 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let mean = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let std_dev = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[3], cell) {
Ok(b) => b,
Err(e) => return e,
};
// Excel domain checks
if x <= 0.0 || std_dev <= 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for LOGNORM.DIST".to_string(),
};
}
let dist = match LogNormal::new(mean, std_dev) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for LOGNORM.DIST".to_string(),
}
}
};
let result = if cumulative { dist.cdf(x) } else { dist.pdf(x) };
if !result.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for LOGNORM.DIST".to_string(),
};
}
CalcResult::Number(result)
}
pub(crate) fn fn_log_norm_inv(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
use statrs::distribution::{ContinuousCDF, LogNormal};
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let mean = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let std_dev = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
// Excel domain checks
if p <= 0.0 || p >= 1.0 || std_dev <= 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for LOGNORM.INV".to_string(),
};
}
let dist = match LogNormal::new(mean, std_dev) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for LOGNORM.INV".to_string(),
}
}
};
let result = dist.inverse_cdf(p);
if !result.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameter for LOGNORM.INV".to_string(),
};
}
CalcResult::Number(result)
}
}

View File

@@ -0,0 +1,23 @@
mod beta;
mod binom;
mod chisq;
mod count_and_average;
mod covariance;
mod devsq;
mod exponential;
mod fisher;
mod gamma;
mod geomean;
mod hypegeom;
mod if_ifs;
mod log_normal;
mod normal;
mod pearson;
mod phi;
mod poisson;
mod standard_dev;
mod standardize;
mod t_dist;
mod variance;
mod weibull;
mod z_test;

View File

@@ -0,0 +1,325 @@
use statrs::distribution::{Continuous, ContinuousCDF, Normal, StudentsT};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
// NORM.DIST(x, mean, standard_dev, cumulative)
pub(crate) fn fn_norm_dist(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 4 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let mean = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let std_dev = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[3], cell) {
Ok(b) => b,
Err(e) => return e,
};
// Excel: standard_dev must be > 0
if std_dev <= 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "standard_dev must be > 0 in NORM.DIST".to_string(),
};
}
let dist = match Normal::new(mean, std_dev) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for NORM.DIST".to_string(),
}
}
};
let result = if cumulative { dist.cdf(x) } else { dist.pdf(x) };
if !result.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for NORM.DIST".to_string(),
};
}
CalcResult::Number(result)
}
// NORM.INV(probability, mean, standard_dev)
pub(crate) fn fn_norm_inv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let mean = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let std_dev = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
if p <= 0.0 || p >= 1.0 || std_dev <= 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for NORM.INV".to_string(),
};
}
let dist = match Normal::new(mean, std_dev) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for NORM.INV".to_string(),
}
}
};
let x = dist.inverse_cdf(p);
if !x.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for NORM.INV".to_string(),
};
}
CalcResult::Number(x)
}
// NORM.S.DIST(z, cumulative)
pub(crate) fn fn_norm_s_dist(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let z = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[1], cell) {
Ok(b) => b,
Err(e) => return e,
};
let dist = match Normal::new(0.0, 1.0) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::ERROR,
origin: cell,
message: "Failed to construct standard normal distribution".to_string(),
}
}
};
let result = if cumulative { dist.cdf(z) } else { dist.pdf(z) };
if !result.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for NORM.S.DIST".to_string(),
};
}
CalcResult::Number(result)
}
// NORM.S.INV(probability)
pub(crate) fn fn_norm_s_inv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 1 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
if p <= 0.0 || p >= 1.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "probability must be in (0,1) in NORM.S.INV".to_string(),
};
}
let dist = match Normal::new(0.0, 1.0) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::ERROR,
origin: cell,
message: "Failed to construct standard normal distribution".to_string(),
}
}
};
let z = dist.inverse_cdf(p);
if !z.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for NORM.S.INV".to_string(),
};
}
CalcResult::Number(z)
}
pub(crate) fn fn_confidence_norm(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let alpha = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let std_dev = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let size = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.floor(),
Err(e) => return e,
};
if alpha <= 0.0 || alpha >= 1.0 || std_dev <= 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for CONFIDENCE.NORM".to_string(),
};
}
if size < 1.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Sample size must be at least 1".to_string(),
};
}
let normal = match Normal::new(0.0, 1.0) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
"Failed to construct normal distribution".to_string(),
)
}
};
let quantile = normal.inverse_cdf(1.0 - alpha / 2.0);
if !quantile.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid quantile for CONFIDENCE.NORM".to_string(),
};
}
let margin = quantile * std_dev / size.sqrt();
CalcResult::Number(margin)
}
pub(crate) fn fn_confidence_t(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let alpha = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let std_dev = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let size = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// Domain checks
if alpha <= 0.0 || alpha >= 1.0 || std_dev <= 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for CONFIDENCE.T".to_string(),
};
}
// Need at least 2 observations so df = n - 1 > 0
if size < 2.0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "Sample size must be at least 2".to_string(),
};
}
let df = size - 1.0;
let t_dist = match StudentsT::new(0.0, 1.0, df) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
"Failed to construct Student's t distribution".to_string(),
)
}
};
// Two-sided CI => use 1 - alpha/2
let t_crit = t_dist.inverse_cdf(1.0 - alpha / 2.0);
if !t_crit.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid quantile for CONFIDENCE.T".to_string(),
};
}
let margin = t_crit * std_dev / size.sqrt();
CalcResult::Number(margin)
}
}

View File

@@ -0,0 +1,235 @@
use crate::expressions::types::CellReferenceIndex;
use crate::functions::statistical::chisq::is_same_shape_or_1d;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
// PEARSON(array1, array2)
pub(crate) fn fn_pearson(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let left_arg = self.evaluate_node_in_context(&args[0], cell);
let right_arg = self.evaluate_node_in_context(&args[1], cell);
let (values_left, values_right) = match (left_arg, right_arg) {
(
CalcResult::Range {
left: l1,
right: r1,
},
CalcResult::Range {
left: l2,
right: r2,
},
) => {
if l1.sheet != l2.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let rows1 = r1.row - l1.row + 1;
let cols1 = r1.column - l1.column + 1;
let rows2 = r2.row - l2.row + 1;
let cols2 = r2.column - l2.column + 1;
if !is_same_shape_or_1d(rows1, cols1, rows2, cols2) {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges must be of the same shape".to_string(),
);
}
let values_left = match self.values_from_range(l1, r1) {
Err(error) => return error,
Ok(v) => v,
};
let values_right = match self.values_from_range(l2, r2) {
Err(error) => return error,
Ok(v) => v,
};
(values_left, values_right)
}
(
CalcResult::Array(left),
CalcResult::Range {
left: l2,
right: r2,
},
) => {
let rows2 = r2.row - l2.row + 1;
let cols2 = r2.column - l2.column + 1;
let rows1 = left.len() as i32;
let cols1 = if rows1 > 0 { left[0].len() as i32 } else { 0 };
if !is_same_shape_or_1d(rows1, cols1, rows2, cols2) {
return CalcResult::new_error(
Error::VALUE,
cell,
"Array and range must be of the same shape".to_string(),
);
}
let values_left = match self.values_from_array(left) {
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in first array: {:?}", error),
);
}
Ok(v) => v,
};
let values_right = match self.values_from_range(l2, r2) {
Err(error) => return error,
Ok(v) => v,
};
(values_left, values_right)
}
(
CalcResult::Range {
left: l1,
right: r1,
},
CalcResult::Array(right),
) => {
let rows1 = r1.row - l1.row + 1;
let cols1 = r1.column - l1.column + 1;
let rows2 = right.len() as i32;
let cols2 = if rows2 > 0 { right[0].len() as i32 } else { 0 };
if !is_same_shape_or_1d(rows1, cols1, rows2, cols2) {
return CalcResult::new_error(
Error::VALUE,
cell,
"Range and array must be of the same shape".to_string(),
);
}
let values_left = match self.values_from_range(l1, r1) {
Err(error) => return error,
Ok(v) => v,
};
let values_right = match self.values_from_array(right) {
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in second array: {:?}", error),
);
}
Ok(v) => v,
};
(values_left, values_right)
}
(CalcResult::Array(left), CalcResult::Array(right)) => {
let rows1 = left.len() as i32;
let rows2 = right.len() as i32;
let cols1 = if rows1 > 0 { left[0].len() as i32 } else { 0 };
let cols2 = if rows2 > 0 { right[0].len() as i32 } else { 0 };
if !is_same_shape_or_1d(rows1, cols1, rows2, cols2) {
return CalcResult::new_error(
Error::VALUE,
cell,
"Arrays must be of the same shape".to_string(),
);
}
let values_left = match self.values_from_array(left) {
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in first array: {:?}", error),
);
}
Ok(v) => v,
};
let values_right = match self.values_from_array(right) {
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in second array: {:?}", error),
);
}
Ok(v) => v,
};
(values_left, values_right)
}
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"Both arguments must be ranges or arrays".to_string(),
);
}
};
// Flatten into (x, y) pairs, skipping non-numeric entries (None)
let mut n: f64 = 0.0;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_x2 = 0.0;
let mut sum_y2 = 0.0;
let mut sum_xy = 0.0;
let len = values_left.len().min(values_right.len());
for i in 0..len {
match (values_left[i], values_right[i]) {
(Some(x), Some(y)) => {
n += 1.0;
sum_x += x;
sum_y += y;
sum_x2 += x * x;
sum_y2 += y * y;
sum_xy += x * y;
}
_ => {
// Ignore pairs where at least one side is non-numeric
}
}
}
if n < 2.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"PEARSON requires at least two numeric pairs".to_string(),
);
}
// Pearson correlation:
// r = [ n*Σxy - (Σx)(Σy) ] / sqrt( [n*Σx² - (Σx)²] [n*Σy² - (Σy)²] )
let num = n * sum_xy - sum_x * sum_y;
let denom_x = n * sum_x2 - sum_x * sum_x;
let denom_y = n * sum_y2 - sum_y * sum_y;
if denom_x.abs() < 1e-15 || denom_y.abs() < 1e-15 {
// Zero variance in at least one series
return CalcResult::new_error(
Error::DIV,
cell,
"PEARSON cannot be computed when one series has zero variance".to_string(),
);
}
let denom = (denom_x * denom_y).sqrt();
let r = num / denom;
CalcResult::Number(r)
}
}

View File

@@ -0,0 +1,21 @@
use crate::expressions::types::CellReferenceIndex;
use crate::{calc_result::CalcResult, expressions::parser::Node, model::Model};
impl Model {
// PHI(x) = standard normal PDF at x
pub(crate) fn fn_phi(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 1 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
// Standard normal PDF: (1 / sqrt(2π)) * exp(-x^2 / 2)
let result = (-(x * x) / 2.0).exp() / (2.0 * std::f64::consts::PI).sqrt();
CalcResult::Number(result)
}
}

View File

@@ -0,0 +1,94 @@
use statrs::distribution::{Discrete, DiscreteCDF, Poisson};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
// =POISSON.DIST(x, mean, cumulative)
pub(crate) fn fn_poisson_dist(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
// x
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
// mean (lambda)
let lambda = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[2], cell) {
Ok(b) => b,
Err(e) => return e,
};
if x < 0.0 || lambda < 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for POISSON.DIST".to_string(),
};
}
// Guard against insane k for u64
if x < 0.0 || x > (u64::MAX as f64) {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for POISSON.DIST".to_string(),
};
}
let k = x as u64;
// Special-case lambda = 0: degenerate distribution at 0
if lambda == 0.0 {
let result = if cumulative {
// For x >= 0, P(X <= x) = 1
1.0
} else {
// P(X = 0) = 1, P(X = k>0) = 0
if k == 0 {
1.0
} else {
0.0
}
};
return CalcResult::Number(result);
}
let dist = match Poisson::new(lambda) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for POISSON.DIST".to_string(),
}
}
};
let prob = if cumulative { dist.cdf(k) } else { dist.pmf(k) };
if !prob.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for POISSON.DIST".to_string(),
};
}
CalcResult::Number(prob)
}
}

View File

@@ -0,0 +1,519 @@
use crate::constants::{LAST_COLUMN, LAST_ROW};
use crate::expressions::parser::ArrayNode;
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
pub(crate) fn fn_stdev_p(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut sum = 0.0;
let mut sumsq = 0.0;
let mut count: u64 = 0;
#[inline]
fn accumulate(sum: &mut f64, sumsq: &mut f64, count: &mut u64, value: f64) {
*sum += value;
*sumsq += value * value;
*count += 1;
}
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..row2 + 1 {
for column in column1..(column2 + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric
}
}
}
}
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// ignore non-numeric
}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric
}
}
}
if count == 0 {
return CalcResult::new_error(
Error::DIV,
cell,
"STDEV.P with no numeric data".to_string(),
);
}
let n = count as f64;
let mut var = (sumsq - (sum * sum) / n) / n;
// clamp tiny negatives from FP noise
if var < 0.0 && var > -1e-12 {
var = 0.0;
}
CalcResult::Number(var.sqrt())
}
pub(crate) fn fn_stdev_s(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut sum = 0.0;
let mut sumsq = 0.0;
let mut count: u64 = 0;
#[inline]
fn accumulate(sum: &mut f64, sumsq: &mut f64, count: &mut u64, value: f64) {
*sum += value;
*sumsq += value * value;
*count += 1;
}
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..row2 + 1 {
for column in column1..(column2 + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric
}
}
}
}
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// ignore non-numeric
}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric
}
}
}
if count <= 1 {
return CalcResult::new_error(
Error::DIV,
cell,
"STDEV.S requires at least two numeric values".to_string(),
);
}
let n = count as f64;
let mut var = (sumsq - (sum * sum) / n) / (n - 1.0);
if var < 0.0 && var > -1e-12 {
var = 0.0;
}
CalcResult::Number(var.sqrt())
}
pub(crate) fn fn_stdeva(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut sum = 0.0;
let mut sumsq = 0.0;
let mut count: u64 = 0;
#[inline]
fn accumulate(sum: &mut f64, sumsq: &mut f64, count: &mut u64, value: f64) {
*sum += value;
*sumsq += value * value;
*count += 1;
}
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..row2 + 1 {
for column in column1..(column2 + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::String(_) => {
accumulate(&mut sum, &mut sumsq, &mut count, 0.0);
}
CalcResult::Boolean(value) => {
let val = if value { 1.0 } else { 0.0 };
accumulate(&mut sum, &mut sumsq, &mut count, val);
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric for now
}
}
}
}
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// ignore non-numeric for now
}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric for now
}
}
}
if count <= 1 {
return CalcResult::new_error(
Error::DIV,
cell,
"STDEVA requires at least two numeric values".to_string(),
);
}
let n = count as f64;
let mut var = (sumsq - (sum * sum) / n) / (n - 1.0);
if var < 0.0 && var > -1e-12 {
var = 0.0;
}
CalcResult::Number(var.sqrt())
}
pub(crate) fn fn_stdevpa(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut sum = 0.0;
let mut sumsq = 0.0;
let mut count: u64 = 0;
#[inline]
fn accumulate(sum: &mut f64, sumsq: &mut f64, count: &mut u64, value: f64) {
*sum += value;
*sumsq += value * value;
*count += 1;
}
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..row2 + 1 {
for column in column1..(column2 + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::String(_) => {
accumulate(&mut sum, &mut sumsq, &mut count, 0.0);
}
CalcResult::Boolean(value) => {
let val = if value { 1.0 } else { 0.0 };
accumulate(&mut sum, &mut sumsq, &mut count, val);
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric for now
}
}
}
}
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// ignore non-numeric for now
}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric for now
}
}
}
if count == 0 {
return CalcResult::new_error(
Error::DIV,
cell,
"STDEVPA with no numeric data".to_string(),
);
}
let n = count as f64;
let mut var = (sumsq - (sum * sum) / n) / n;
if var < 0.0 && var > -1e-12 {
var = 0.0;
}
CalcResult::Number(var.sqrt())
}
}

View File

@@ -0,0 +1,38 @@
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
pub(crate) fn fn_standardize(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
// STANDARDIZE(x, mean, standard_dev)
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let mean = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let std_dev = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
if std_dev <= 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "standard_dev must be > 0 in STANDARDIZE".to_string(),
};
}
let z = (x - mean) / std_dev;
CalcResult::Number(z)
}
}

View File

@@ -0,0 +1,588 @@
use statrs::distribution::{Continuous, ContinuousCDF, StudentsT};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
fn mean(xs: &[f64]) -> f64 {
let n = xs.len();
if n == 0 {
return 0.0;
}
let mut s = 0.0;
for &x in xs {
s += x;
}
s / (n as f64)
}
fn sample_var(xs: &[f64]) -> f64 {
let n = xs.len();
if n < 2 {
return 0.0;
}
let m = mean(xs);
let mut s = 0.0;
for &x in xs {
let d = x - m;
s += d * d;
}
s / ((n - 1) as f64)
}
enum TTestType {
Paired,
TwoSampleEqualVar,
TwoSampleUnequalVar,
}
enum TTestTails {
OneTailed,
TwoTailed,
}
impl Model {
// T.DIST(x, deg_freedom, cumulative)
pub(crate) fn fn_t_dist(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[2], cell) {
Ok(b) => b,
Err(e) => return e,
};
if df < 1.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "deg_freedom must be >= 1 in T.DIST".to_string(),
};
}
let dist = match StudentsT::new(0.0, 1.0, df) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for T.DIST".to_string(),
}
}
};
let result = if cumulative { dist.cdf(x) } else { dist.pdf(x) };
if !result.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for T.DIST".to_string(),
};
}
CalcResult::Number(result)
}
// T.DIST.2T(x, deg_freedom)
pub(crate) fn fn_t_dist_2t(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
if x < 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "x must be >= 0 in T.DIST.2T".to_string(),
};
}
if df < 1.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "deg_freedom must be >= 1 in T.DIST.2T".to_string(),
};
}
let dist = match StudentsT::new(0.0, 1.0, df) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for T.DIST.2T".to_string(),
}
}
};
let upper_tail = 1.0 - dist.cdf(x);
let mut result = 2.0 * upper_tail;
result = result.clamp(0.0, 1.0);
if !result.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for T.DIST.2T".to_string(),
};
}
CalcResult::Number(result)
}
// T.DIST.RT(x, deg_freedom)
pub(crate) fn fn_t_dist_rt(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
if df < 1.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "deg_freedom must be >= 1 in T.DIST.RT".to_string(),
};
}
let dist = match StudentsT::new(0.0, 1.0, df) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for T.DIST.RT".to_string(),
}
}
};
let result = 1.0 - dist.cdf(x);
if !result.is_finite() || result < 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for T.DIST.RT".to_string(),
};
}
CalcResult::Number(result)
}
// T.INV(probability, deg_freedom)
pub(crate) fn fn_t_inv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
if p <= 0.0 || p >= 1.0 || df < 1.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for T.INV".to_string(),
};
}
let dist = match StudentsT::new(0.0, 1.0, df) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for T.INV".to_string(),
}
}
};
let x = dist.inverse_cdf(p);
if !x.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for T.INV".to_string(),
};
}
CalcResult::Number(x)
}
// T.INV.2T(probability, deg_freedom)
pub(crate) fn fn_t_inv_2t(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let p = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let df = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc(),
Err(e) => return e,
};
if p <= 0.0 || p > 1.0 || df < 1.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for T.INV.2T".to_string(),
};
}
let dist = match StudentsT::new(0.0, 1.0, df) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for T.INV.2T".to_string(),
}
}
};
// Two-sided: F(x) = 1 - p/2
let target_cdf = 1.0 - p / 2.0;
let x = dist.inverse_cdf(target_cdf);
if !x.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for T.INV.2T".to_string(),
};
}
CalcResult::Number(x.abs())
}
// T.TEST(array1, array2, tails, type)
pub(crate) fn fn_t_test(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 4 {
return CalcResult::new_args_number_error(cell);
}
let values1_opts = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Range { left, right } => match self.values_from_range(left, right) {
Ok(v) => v,
Err(error) => return error,
},
CalcResult::Array(a) => match self.values_from_array(a) {
Ok(v) => v,
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in first array: {:?}", error),
);
}
},
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"First argument must be a range or array".to_string(),
);
}
};
let values2_opts = match self.evaluate_node_in_context(&args[1], cell) {
CalcResult::Range { left, right } => match self.values_from_range(left, right) {
Ok(v) => v,
Err(error) => return error,
},
CalcResult::Array(a) => match self.values_from_array(a) {
Ok(v) => v,
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in second array: {:?}", error),
);
}
},
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"Second argument must be a range or array".to_string(),
);
}
};
let tails = match self.get_number(&args[2], cell) {
Ok(f) => {
let tf = f.trunc();
if tf == 1.0 {
TTestTails::OneTailed
} else if tf == 2.0 {
TTestTails::TwoTailed
} else {
return CalcResult::new_error(
Error::NUM,
cell,
"tails must be 1 or 2".to_string(),
);
}
}
Err(e) => return e,
};
let test_type = match self.get_number(&args[3], cell) {
Ok(f) => {
let tf = f.trunc();
match tf {
1.0 => TTestType::Paired,
2.0 => TTestType::TwoSampleEqualVar,
3.0 => TTestType::TwoSampleUnequalVar,
_ => {
return CalcResult::new_error(
Error::NUM,
cell,
"type must be 1, 2, or 3".to_string(),
);
}
}
}
Err(e) => return e,
};
let (values1, values2): (Vec<f64>, Vec<f64>) = if matches!(test_type, TTestType::Paired) {
values1_opts
.into_iter()
.zip(values2_opts)
.filter_map(|(o1, o2)| match (o1, o2) {
(Some(v1), Some(v2)) => Some((v1, v2)),
_ => None, // skip if either is None
})
.unzip()
} else {
// keep only numeric entries, ignore non-numeric (Option::None)
let v1: Vec<f64> = values1_opts.into_iter().flatten().collect();
let v2: Vec<f64> = values2_opts.into_iter().flatten().collect();
(v1, v2)
};
let n1 = values1.len();
let n2 = values2.len();
if n1 == 0 || n2 == 0 {
return CalcResult::new_error(
Error::DIV,
cell,
"T.TEST requires non-empty samples".to_string(),
);
}
let (t_stat, df) = match test_type {
TTestType::Paired => {
if n1 != n2 {
return CalcResult::new_error(
Error::NA,
cell,
"For paired T.TEST, both samples must have the same length".to_string(),
);
}
if n1 < 2 {
return CalcResult::new_error(
Error::DIV,
cell,
"Paired T.TEST requires at least two pairs".to_string(),
);
}
let mut diffs = Vec::with_capacity(n1);
for i in 0..n1 {
diffs.push(values1[i] - values2[i]);
}
let nd = diffs.len();
let md = mean(&diffs);
let vd = sample_var(&diffs);
if vd <= 0.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Zero variance in paired T.TEST".to_string(),
);
}
let sd = vd.sqrt();
let t_stat = md / (sd / (nd as f64).sqrt());
let df = (nd - 1) as f64;
(t_stat, df)
}
// 2: two-sample, equal variance (homoscedastic)
TTestType::TwoSampleEqualVar => {
if n1 < 2 || n2 < 2 {
return CalcResult::new_error(
Error::DIV,
cell,
"Two-sample T.TEST type 2 requires at least two values in each sample"
.to_string(),
);
}
let m1 = mean(&values1);
let m2 = mean(&values2);
let v1 = sample_var(&values1);
let v2 = sample_var(&values2);
let df_i = (n1 + n2 - 2) as i32;
if df_i <= 0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Degrees of freedom must be positive in T.TEST type 2".to_string(),
);
}
let df = df_i as f64;
let sp2 = (((n1 - 1) as f64) * v1 + ((n2 - 1) as f64) * v2) / df; // pooled variance
if sp2 <= 0.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Zero pooled variance in T.TEST type 2".to_string(),
);
}
let denom = (sp2 * (1.0 / (n1 as f64) + 1.0 / (n2 as f64))).sqrt();
if denom == 0.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Zero denominator in T.TEST type 2".to_string(),
);
}
let t_stat = (m1 - m2) / denom;
(t_stat, df)
}
// two-sample, unequal variance (Welch)
TTestType::TwoSampleUnequalVar => {
if n1 < 2 || n2 < 2 {
return CalcResult::new_error(
Error::DIV,
cell,
"Two-sample T.TEST type 3 requires at least two values in each sample"
.to_string(),
);
}
let m1 = mean(&values1);
let m2 = mean(&values2);
let v1 = sample_var(&values1);
let v2 = sample_var(&values2);
let s1n = v1 / (n1 as f64);
let s2n = v2 / (n2 as f64);
let denom = (s1n + s2n).sqrt();
if denom == 0.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Zero denominator in T.TEST type 3".to_string(),
);
}
let t_stat = (m1 - m2) / denom;
let num_df = (s1n + s2n).powi(2);
let den_df = (s1n * s1n) / ((n1 - 1) as f64) + (s2n * s2n) / ((n2 - 1) as f64);
if den_df == 0.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Invalid degrees of freedom in T.TEST type 3".to_string(),
);
}
let df = num_df / den_df;
(t_stat, df)
}
};
if df <= 0.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Degrees of freedom must be positive in T.TEST".to_string(),
);
}
let dist = match StudentsT::new(0.0, 1.0, df) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Invalid parameters for Student's t distribution".to_string(),
);
}
};
let t_abs = t_stat.abs();
let cdf = dist.cdf(t_abs);
let mut p = match tails {
TTestTails::OneTailed => 1.0 - cdf,
TTestTails::TwoTailed => 2.0 * (1.0 - cdf),
};
// clamp tiny fp noise
if p < 0.0 && p > -1e-15 {
p = 0.0;
}
if p > 1.0 && p < 1.0 + 1e-15 {
p = 1.0;
}
CalcResult::Number(p)
}
}

View File

@@ -0,0 +1,518 @@
use crate::constants::{LAST_COLUMN, LAST_ROW};
use crate::expressions::parser::ArrayNode;
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
pub(crate) fn fn_var_p(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut sum = 0.0;
let mut sumsq = 0.0;
let mut count: u64 = 0;
#[inline]
fn accumulate(sum: &mut f64, sumsq: &mut f64, count: &mut u64, value: f64) {
*sum += value;
*sumsq += value * value;
*count += 1;
}
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..row2 + 1 {
for column in column1..(column2 + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric
}
}
}
}
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// ignore non-numeric
}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric
}
}
}
if count == 0 {
return CalcResult::new_error(
Error::DIV,
cell,
"VAR.P with no numeric data".to_string(),
);
}
let n = count as f64;
let mut var = (sumsq - (sum * sum) / n) / n;
if var < 0.0 && var > -1e-12 {
var = 0.0;
}
CalcResult::Number(var)
}
pub(crate) fn fn_var_s(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut sum = 0.0;
let mut sumsq = 0.0;
let mut count: u64 = 0;
#[inline]
fn accumulate(sum: &mut f64, sumsq: &mut f64, count: &mut u64, value: f64) {
*sum += value;
*sumsq += value * value;
*count += 1;
}
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..row2 + 1 {
for column in column1..(column2 + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric
}
}
}
}
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// ignore non-numeric
}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric
}
}
}
if count <= 1 {
return CalcResult::new_error(
Error::DIV,
cell,
"VAR.S requires at least two numeric values".to_string(),
);
}
let n = count as f64;
let mut var = (sumsq - (sum * sum) / n) / (n - 1.0);
if var < 0.0 && var > -1e-12 {
var = 0.0;
}
CalcResult::Number(var)
}
pub(crate) fn fn_vara(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut sum = 0.0;
let mut sumsq = 0.0;
let mut count: u64 = 0;
#[inline]
fn accumulate(sum: &mut f64, sumsq: &mut f64, count: &mut u64, value: f64) {
*sum += value;
*sumsq += value * value;
*count += 1;
}
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..=row2 {
for column in column1..=column2 {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::String(_) => {
accumulate(&mut sum, &mut sumsq, &mut count, 0.0);
}
CalcResult::Boolean(value) => {
let val = if value { 1.0 } else { 0.0 };
accumulate(&mut sum, &mut sumsq, &mut count, val);
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric for now (A semantics to be added)
}
}
}
}
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// ignore non-numeric for now (A semantics to be added)
}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric for now (A semantics to be added)
}
}
}
if count <= 1 {
return CalcResult::new_error(
Error::DIV,
cell,
"VARA requires at least two numeric values".to_string(),
);
}
let n = count as f64;
let mut var = (sumsq - (sum * sum) / n) / (n - 1.0);
if var < 0.0 && var > -1e-12 {
var = 0.0;
}
CalcResult::Number(var)
}
pub(crate) fn fn_varpa(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut sum = 0.0;
let mut sumsq = 0.0;
let mut count: u64 = 0;
#[inline]
fn accumulate(sum: &mut f64, sumsq: &mut f64, count: &mut u64, value: f64) {
*sum += value;
*sumsq += value * value;
*count += 1;
}
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
let row1 = left.row;
let mut row2 = right.row;
let column1 = left.column;
let mut column2 = right.column;
if row1 == 1 && row2 == LAST_ROW {
row2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_row,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
if column1 == 1 && column2 == LAST_COLUMN {
column2 = match self.workbook.worksheet(left.sheet) {
Ok(s) => s.dimension().max_column,
Err(_) => {
return CalcResult::new_error(
Error::ERROR,
cell,
format!("Invalid worksheet index: '{}'", left.sheet),
);
}
};
}
for row in row1..row2 + 1 {
for column in column1..(column2 + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
CalcResult::String(_) => {
accumulate(&mut sum, &mut sumsq, &mut count, 0.0);
}
CalcResult::Boolean(value) => {
let val = if value { 1.0 } else { 0.0 };
accumulate(&mut sum, &mut sumsq, &mut count, val);
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric for now
}
}
}
}
}
CalcResult::Array(array) => {
for row in array {
for value in row {
match value {
ArrayNode::Number(value) => {
accumulate(&mut sum, &mut sumsq, &mut count, value);
}
ArrayNode::Error(error) => {
return CalcResult::Error {
error,
origin: cell,
message: "Error in array".to_string(),
}
}
_ => {
// ignore non-numeric for now
}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
_ => {
// ignore non-numeric for now
}
}
}
if count == 0 {
return CalcResult::new_error(
Error::DIV,
cell,
"VARPA with no numeric data".to_string(),
);
}
let n = count as f64;
let mut var = (sumsq - (sum * sum) / n) / n;
if var < 0.0 && var > -1e-12 {
var = 0.0;
}
CalcResult::Number(var)
}
}

View File

@@ -0,0 +1,71 @@
use statrs::distribution::{Continuous, ContinuousCDF, Weibull};
use crate::expressions::types::CellReferenceIndex;
use crate::{
calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model,
};
impl Model {
// WEIBULL.DIST(x, alpha, beta, cumulative)
pub(crate) fn fn_weibull_dist(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> CalcResult {
if args.len() != 4 {
return CalcResult::new_args_number_error(cell);
}
let x = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(e) => return e,
};
let alpha = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(e) => return e,
};
let beta = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(e) => return e,
};
let cumulative = match self.get_boolean(&args[3], cell) {
Ok(b) => b,
Err(e) => return e,
};
if x < 0.0 || alpha <= 0.0 || beta <= 0.0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for WEIBULL.DIST".to_string(),
};
}
// statrs::Weibull: shape = k (alpha), scale = lambda (beta)
let dist = match Weibull::new(alpha, beta) {
Ok(d) => d,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid parameters for WEIBULL.DIST".to_string(),
}
}
};
let result = if cumulative { dist.cdf(x) } else { dist.pdf(x) };
if !result.is_finite() {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Invalid result for WEIBULL.DIST".to_string(),
};
}
CalcResult::Number(result)
}
}

View File

@@ -0,0 +1,171 @@
use statrs::distribution::{ContinuousCDF, Normal};
use crate::expressions::token::Error;
use crate::expressions::types::CellReferenceIndex;
use crate::{calc_result::CalcResult, expressions::parser::Node, model::Model};
impl Model {
// Z.TEST(array, x, [sigma])
pub(crate) fn fn_z_test(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
// 2 or 3 arguments
if args.len() < 2 || args.len() > 3 {
return CalcResult::new_args_number_error(cell);
}
let array_arg = self.evaluate_node_in_context(&args[0], cell);
// Flatten first argument into Vec<Option<f64>> (numeric / non-numeric)
let values = match array_arg {
CalcResult::Range { left, right } => match self.values_from_range(left, right) {
Ok(v) => v,
Err(error) => return error,
},
CalcResult::Array(array) => match self.values_from_array(array) {
Ok(v) => v,
Err(error) => {
return CalcResult::new_error(
Error::VALUE,
cell,
format!("Error in array argument: {:?}", error),
);
}
},
CalcResult::Number(v) => vec![Some(v)],
error @ CalcResult::Error { .. } => return error,
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"Z.TEST first argument must be a range or array".to_string(),
);
}
};
// Collect basic stats on numeric entries
let mut sum = 0.0;
let mut count: u64 = 0;
for x in values.iter().flatten() {
sum += x;
count += 1;
}
// Excel: if array has no numeric values -> #N/A
if count == 0 {
return CalcResult::new_error(
Error::NA,
cell,
"Z.TEST array has no numeric data".to_string(),
);
}
let n = count as f64;
let mean = sum / n;
// x argument (hypothesized population mean)
let x_value = match self.evaluate_node_in_context(&args[1], cell) {
CalcResult::Number(v) => v,
error @ CalcResult::Error { .. } => return error,
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"Z.TEST second argument (x) must be numeric".to_string(),
);
}
};
// Optional sigma
let mut sigma: Option<f64> = None;
if args.len() == 3 {
match self.evaluate_node_in_context(&args[2], cell) {
CalcResult::Number(v) => {
if v == 0.0 {
return CalcResult::new_error(
Error::NUM,
cell,
"Z.TEST sigma cannot be zero".to_string(),
);
}
sigma = Some(v);
}
error @ CalcResult::Error { .. } => return error,
_ => {
return CalcResult::new_error(
Error::VALUE,
cell,
"Z.TEST sigma (third argument) must be numeric".to_string(),
);
}
}
}
// If sigma omitted, use sample standard deviation STDEV(array)
let sigma_value = if let Some(s) = sigma {
s
} else {
// Excel: if only one numeric value and sigma omitted -> #DIV/0!
if count <= 1 {
return CalcResult::new_error(
Error::DIV,
cell,
"Z.TEST requires at least two values when sigma is omitted".to_string(),
);
}
// Compute sum of squared deviations
let mut sumsq_dev = 0.0;
for x in values.iter().flatten() {
let d = x - mean;
sumsq_dev += d * d;
}
let var = sumsq_dev / (n - 1.0);
if var <= 0.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Z.TEST standard deviation is zero".to_string(),
);
}
var.sqrt()
};
// Compute z statistic: (mean - x) / (sigma / sqrt(n))
let denom = sigma_value / n.sqrt();
if denom == 0.0 {
return CalcResult::new_error(
Error::DIV,
cell,
"Z.TEST denominator is zero".to_string(),
);
}
let z = (mean - x_value) / denom;
// Standard normal CDF
let dist = match Normal::new(0.0, 1.0) {
Ok(d) => d,
Err(_) => {
return CalcResult::new_error(
Error::NUM,
cell,
"Cannot create standard normal distribution in Z.TEST".to_string(),
);
}
};
let mut p = 1.0 - dist.cdf(z);
// clamp tiny FP noise
if p < 0.0 && p > -1e-15 {
p = 0.0;
}
if p > 1.0 && p < 1.0 + 1e-15 {
p = 1.0;
}
CalcResult::Number(p)
}
}

View File

@@ -1,7 +1,10 @@
#[cfg(feature = "use_regex_lite")] #[cfg(feature = "use_regex_lite")]
use regex_lite as regex; use regex_lite as regex;
use crate::{calc_result::CalcResult, expressions::token::is_english_error_string}; use crate::{
calc_result::CalcResult, expressions::token::is_english_error_string,
number_format::to_excel_precision,
};
/// This test for exact match (modulo case). /// This test for exact match (modulo case).
/// * strings are not cast into bools or numbers /// * strings are not cast into bools or numbers
@@ -34,6 +37,8 @@ pub(crate) fn values_are_equal(left: &CalcResult, right: &CalcResult) -> bool {
pub(crate) fn compare_values(left: &CalcResult, right: &CalcResult) -> i32 { pub(crate) fn compare_values(left: &CalcResult, right: &CalcResult) -> i32 {
match (left, right) { match (left, right) {
(CalcResult::Number(value1), CalcResult::Number(value2)) => { (CalcResult::Number(value1), CalcResult::Number(value2)) => {
let value1 = to_excel_precision(*value1, 15);
let value2 = to_excel_precision(*value2, 15);
if (value2 - value1).abs() < f64::EPSILON { if (value2 - value1).abs() < f64::EPSILON {
return 0; return 0;
} }

View File

@@ -1,7 +1,6 @@
use std::collections::HashMap; use std::{collections::HashMap, sync::OnceLock};
use bitcode::{Decode, Encode}; use bitcode::{Decode, Encode};
use once_cell::sync::Lazy;
#[derive(Encode, Decode, Clone)] #[derive(Encode, Decode, Clone)]
pub struct Booleans { pub struct Booleans {
@@ -31,14 +30,17 @@ pub struct Language {
pub errors: Errors, pub errors: Errors,
} }
static LANGUAGES: OnceLock<HashMap<String, Language>> = OnceLock::new();
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
static LANGUAGES: Lazy<HashMap<String, Language>> = Lazy::new(|| { fn get_languages() -> &'static HashMap<String, Language> {
bitcode::decode(include_bytes!("language.bin")).expect("Failed parsing language file") LANGUAGES.get_or_init(|| {
}); bitcode::decode(include_bytes!("language.bin")).expect("Failed parsing language file")
})
}
pub fn get_language(id: &str) -> Result<&Language, String> { pub fn get_language(id: &str) -> Result<&Language, String> {
let language = LANGUAGES get_languages()
.get(id) .get(id)
.ok_or(format!("Language is not supported: '{id}'"))?; .ok_or_else(|| format!("Language is not supported: '{id}'"))
Ok(language)
} }

View File

@@ -1,7 +1,6 @@
use bitcode::{Decode, Encode}; use std::{collections::HashMap, sync::OnceLock};
use once_cell::sync::Lazy;
use std::collections::HashMap; use bitcode::{Decode, Encode};
#[derive(Encode, Decode, Clone)] #[derive(Encode, Decode, Clone)]
pub struct Locale { pub struct Locale {
@@ -65,12 +64,17 @@ pub struct DecimalFormats {
pub standard: String, pub standard: String,
} }
static LOCALES: OnceLock<HashMap<String, Locale>> = OnceLock::new();
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
static LOCALES: Lazy<HashMap<String, Locale>> = fn get_locales() -> &'static HashMap<String, Locale> {
Lazy::new(|| bitcode::decode(include_bytes!("locales.bin")).expect("Failed parsing locale")); LOCALES.get_or_init(|| {
bitcode::decode(include_bytes!("locales.bin")).expect("Failed parsing locale")
})
}
pub fn get_locale(id: &str) -> Result<&Locale, String> { pub fn get_locale(id: &str) -> Result<&Locale, String> {
// TODO: pass the locale once we implement locales in Rust get_locales()
let locale = LOCALES.get(id).ok_or("Invalid locale")?; .get(id)
Ok(locale) .ok_or_else(|| format!("Invalid locale: '{id}'"))
} }

View File

@@ -2068,21 +2068,7 @@ impl Model {
scope: Option<u32>, scope: Option<u32>,
formula: &str, formula: &str,
) -> Result<(), String> { ) -> Result<(), String> {
if !is_valid_identifier(name) { let sheet_id = self.is_valid_defined_name(name, scope, formula)?;
return Err("Invalid defined name".to_string());
};
let name_upper = name.to_uppercase();
let defined_names = &self.workbook.defined_names;
let sheet_id = match scope {
Some(index) => Some(self.workbook.worksheet(index)?.sheet_id),
None => None,
};
// if the defined name already exist return error
for df in defined_names {
if df.name.to_uppercase() == name_upper && df.sheet_id == sheet_id {
return Err("Defined name already exists".to_string());
}
}
self.workbook.defined_names.push(DefinedName { self.workbook.defined_names.push(DefinedName {
name: name.to_string(), name: name.to_string(),
formula: formula.to_string(), formula: formula.to_string(),
@@ -2093,6 +2079,48 @@ impl Model {
Ok(()) Ok(())
} }
/// Validates if a defined name can be created
pub fn is_valid_defined_name(
&self,
name: &str,
scope: Option<u32>,
formula: &str,
) -> Result<Option<u32>, String> {
if !is_valid_identifier(name) {
return Err("Name: Invalid defined name".to_string());
}
let name_upper = name.to_uppercase();
let defined_names = &self.workbook.defined_names;
let sheet_id = match scope {
Some(index) => match self.workbook.worksheet(index) {
Ok(ws) => Some(ws.sheet_id),
Err(_) => return Err("Scope: Invalid sheet index".to_string()),
},
None => None,
};
// if the defined name already exist return error
for df in defined_names {
if df.name.to_uppercase() == name_upper && df.sheet_id == sheet_id {
return Err("Name: Defined name already exists".to_string());
}
}
// Make sure the formula is valid
match common::ParsedReference::parse_reference_formula(
None,
formula,
&self.locale,
|name| self.get_sheet_index_by_name(name),
) {
Ok(_) => {}
Err(_) => {
return Err("Formula: Invalid defined name formula".to_string());
}
};
Ok(sheet_id)
}
/// Delete defined name of name and scope /// Delete defined name of name and scope
pub fn delete_defined_name(&mut self, name: &str, scope: Option<u32>) -> Result<(), String> { pub fn delete_defined_name(&mut self, name: &str, scope: Option<u32>) -> Result<(), String> {
let name_upper = name.to_uppercase(); let name_upper = name.to_uppercase();
@@ -2126,7 +2154,7 @@ impl Model {
new_formula: &str, new_formula: &str,
) -> Result<(), String> { ) -> Result<(), String> {
if !is_valid_identifier(new_name) { if !is_valid_identifier(new_name) {
return Err("Invalid defined name".to_string()); return Err("Name: Invalid defined name".to_string());
}; };
let name_upper = name.to_uppercase(); let name_upper = name.to_uppercase();
let new_name_upper = new_name.to_uppercase(); let new_name_upper = new_name.to_uppercase();
@@ -2134,18 +2162,28 @@ impl Model {
if name_upper != new_name_upper || scope != new_scope { if name_upper != new_name_upper || scope != new_scope {
for key in self.parsed_defined_names.keys() { for key in self.parsed_defined_names.keys() {
if key.1.to_uppercase() == new_name_upper && key.0 == new_scope { if key.1.to_uppercase() == new_name_upper && key.0 == new_scope {
return Err("Defined name already exists".to_string()); return Err("Name: Defined name already exists".to_string());
} }
} }
} }
let defined_names = &self.workbook.defined_names; let defined_names = &self.workbook.defined_names;
let sheet_id = match scope { let sheet_id = match scope {
Some(index) => Some(self.workbook.worksheet(index)?.sheet_id), Some(index) => Some(
self.workbook
.worksheet(index)
.map_err(|_| "Scope: Invalid sheet index")?
.sheet_id,
),
None => None, None => None,
}; };
let new_sheet_id = match new_scope { let new_sheet_id = match new_scope {
Some(index) => Some(self.workbook.worksheet(index)?.sheet_id), Some(index) => Some(
self.workbook
.worksheet(index)
.map_err(|_| "Scope: Invalid sheet index")?
.sheet_id,
),
None => None, None => None,
}; };

View File

@@ -112,29 +112,36 @@ pub fn to_precision(value: f64, precision: usize) -> f64 {
/// ``` /// ```
/// This intends to be equivalent to the js: `${parseFloat(value.toPrecision(precision)})` /// This intends to be equivalent to the js: `${parseFloat(value.toPrecision(precision)})`
/// See ([ecma](https://tc39.es/ecma262/#sec-number.prototype.toprecision)). /// See ([ecma](https://tc39.es/ecma262/#sec-number.prototype.toprecision)).
/// FIXME: There has to be a better algorithm :/
pub fn to_excel_precision_str(value: f64) -> String { pub fn to_excel_precision_str(value: f64) -> String {
to_precision_str(value, 15) to_precision_str(value, 15)
} }
pub fn to_excel_precision(value: f64, precision: usize) -> f64 {
if !value.is_finite() {
return value;
}
let s = format!("{:.*e}", precision.saturating_sub(1), value);
s.parse::<f64>().unwrap_or(value)
}
pub fn to_precision_str(value: f64, precision: usize) -> String { pub fn to_precision_str(value: f64, precision: usize) -> String {
if value.is_infinite() { if !value.is_finite() {
return "inf".to_string(); if value.is_infinite() {
return "inf".to_string();
} else {
return "NaN".to_string();
}
} }
if value.is_nan() {
return "NaN".to_string(); let s = format!("{:.*e}", precision.saturating_sub(1), value);
} let parsed = s.parse::<f64>().unwrap_or(value);
let exponent = value.abs().log10().floor();
let base = value / 10.0_f64.powf(exponent);
let base = format!("{0:.1$}", base, precision - 1);
let value = format!("{base}e{exponent}").parse::<f64>().unwrap_or({
// TODO: do this in a way that does not require a possible error
0.0
});
// I would love to use the std library. There is not a speed concern here // I would love to use the std library. There is not a speed concern here
// problem is it doesn't do the right thing // problem is it doesn't do the right thing
// Also ryu is my favorite _modern_ algorithm // Also ryu is my favorite _modern_ algorithm
let mut buffer = ryu::Buffer::new(); let mut buffer = ryu::Buffer::new();
let text = buffer.format(value); let text = buffer.format(parsed);
// The above algorithm converts 2 to 2.0 regrettably // The above algorithm converts 2 to 2.0 regrettably
if let Some(stripped) = text.strip_suffix(".0") { if let Some(stripped) = text.strip_suffix(".0") {
return stripped.to_string(); return stripped.to_string();

View File

@@ -133,6 +133,7 @@ fn fn_imcot() {
); );
} }
#[cfg_attr(target_os = "windows", ignore)]
#[test] #[test]
fn fn_imtan() { fn fn_imtan() {
let mut model = new_empty_model(); let mut model = new_empty_model();

View File

@@ -7,6 +7,9 @@ mod test_column_width;
mod test_criteria; mod test_criteria;
mod test_currency; mod test_currency;
mod test_date_and_time; mod test_date_and_time;
mod test_datedif_leap_month_end;
mod test_days360_month_end;
mod test_degrees_radians;
mod test_error_propagation; mod test_error_propagation;
mod test_fn_average; mod test_fn_average;
mod test_fn_averageifs; mod test_fn_averageifs;
@@ -15,6 +18,7 @@ mod test_fn_concatenate;
mod test_fn_count; mod test_fn_count;
mod test_fn_day; mod test_fn_day;
mod test_fn_exact; mod test_fn_exact;
mod test_fn_fact;
mod test_fn_financial; mod test_fn_financial;
mod test_fn_formulatext; mod test_fn_formulatext;
mod test_fn_if; mod test_fn_if;
@@ -27,6 +31,7 @@ mod test_fn_sum;
mod test_fn_sumifs; mod test_fn_sumifs;
mod test_fn_textbefore; mod test_fn_textbefore;
mod test_fn_textjoin; mod test_fn_textjoin;
mod test_fn_time;
mod test_fn_unicode; mod test_fn_unicode;
mod test_frozen_rows_columns; mod test_frozen_rows_columns;
mod test_general; mod test_general;
@@ -43,26 +48,44 @@ mod test_sheets;
mod test_styles; mod test_styles;
mod test_trigonometric; mod test_trigonometric;
mod test_true_false; mod test_true_false;
mod test_weekday_return_types;
mod test_weeknum_return_types;
mod test_workbook; mod test_workbook;
mod test_worksheet; mod test_worksheet;
mod test_yearfrac_basis;
pub(crate) mod util; pub(crate) mod util;
mod engineering; mod engineering;
mod statistical;
mod test_fn_offset; mod test_fn_offset;
mod test_number_format; mod test_number_format;
mod test_arrays; mod test_arrays;
mod test_combin_combina;
mod test_escape_quotes; mod test_escape_quotes;
mod test_even_odd;
mod test_exp_sign;
mod test_extend; mod test_extend;
mod test_fn_datevalue_timevalue;
mod test_fn_fv; mod test_fn_fv;
mod test_fn_round;
mod test_fn_type; mod test_fn_type;
mod test_frozen_rows_and_columns; mod test_frozen_rows_and_columns;
mod test_geomean; mod test_geomean;
mod test_get_cell_content; mod test_get_cell_content;
mod test_implicit_intersection; mod test_implicit_intersection;
mod test_issue_155; mod test_issue_155;
mod test_issue_483;
mod test_ln;
mod test_log;
mod test_log10;
mod test_mod_quotient;
mod test_networkdays;
mod test_now;
mod test_percentage; mod test_percentage;
mod test_set_functions_error_handling; mod test_set_functions_error_handling;
mod test_sheet_names;
mod test_today; mod test_today;
mod test_trigonometric_reciprocals;
mod test_types; mod test_types;
mod user_model; mod user_model;

View File

@@ -0,0 +1,22 @@
mod test_fn_avedev;
mod test_fn_binom;
mod test_fn_chisq;
mod test_fn_chisq_test;
mod test_fn_confidence;
mod test_fn_covariance;
mod test_fn_devsq;
mod test_fn_expon_dist;
mod test_fn_f;
mod test_fn_fisher;
mod test_fn_hyp_geom_dist;
mod test_fn_log_norm;
mod test_fn_norm_dist;
mod test_fn_pearson;
mod test_fn_phi;
mod test_fn_poisson;
mod test_fn_stdev;
mod test_fn_t_dist;
mod test_fn_t_test;
mod test_fn_var;
mod test_fn_weibull;
mod test_fn_z_test;

View File

@@ -0,0 +1,40 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn smoke_test() {
let mut model = new_empty_model();
model._set("A1", "=STDEV.P(10, 12, 23, 23, 16, 23, 21)");
model._set("A2", "=STDEV.S(10, 12, 23, 23, 16, 23, 21)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"5.174505793");
assert_eq!(model._get_text("A2"), *"5.589105048");
}
#[test]
fn numbers() {
let mut model = new_empty_model();
model._set("A2", "24");
model._set("A3", "25");
model._set("A4", "27");
model._set("A5", "23");
model._set("A6", "45");
model._set("A7", "23.5");
model._set("A8", "34");
model._set("A9", "23");
model._set("A10", "23");
model._set("A11", "TRUE");
model._set("A12", "'23");
model._set("A13", "Text");
model._set("A14", "FALSE");
model._set("A15", "45");
model._set("B1", "=AVEDEV(A2:A15)");
model.evaluate();
assert_eq!(model._get_text("B1"), *"7.25");
}

View File

@@ -0,0 +1,86 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_binom_dist_smoke() {
let mut model = new_empty_model();
model._set("A1", "=BINOM.DIST(6, 10, 0.5, TRUE)");
model._set("A2", "=BINOM.DIST(6, 10, 0.5, FALSE)");
model._set("A3", "=BINOM.DIST(6, 10, 0.5)"); // wrong args
model._set("A4", "=BINOM.DIST(6, 10, 0.5, TRUE, FALSE)"); // too many args
model.evaluate();
// P(X <= 6) for X ~ Bin(10, 0.5) = 0.828125
assert_eq!(model._get_text("A1"), *"0.828125");
// P(X = 6) for X ~ Bin(10, 0.5) = 0.205078125
assert_eq!(model._get_text("A2"), *"0.205078125");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
}
#[test]
fn test_fn_binom_dist_range_smoke() {
let mut model = new_empty_model();
model._set("A1", "=BINOM.DIST.RANGE(60, 0.75, 48)");
model._set("A2", "=BINOM.DIST.RANGE(60, 0.75, 45, 50)");
model._set("A3", "=BINOM.DIST.RANGE(60, 1.2, 45, 50)"); // p > 1 -> #NUM!
model._set("A4", "=BINOM.DIST.RANGE(60, 0.75, 50, 45)"); // lower > upper -> #NUM!");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.083974967");
assert_eq!(model._get_text("A2"), *"0.523629793");
assert_eq!(model._get_text("A3"), *"#NUM!");
assert_eq!(model._get_text("A4"), *"#NUM!");
}
#[test]
fn test_fn_binom_inv_smoke() {
let mut model = new_empty_model();
model._set("A1", "=BINOM.INV(6, 0.5, 0.75)");
model._set("A2", "=BINOM.INV(6, 0.5, -0.1)"); // alpha < 0 -> #NUM!
model._set("A3", "=BINOM.INV(6, 1.2, 0.75)"); // p > 1 -> #NUM!
model._set("A4", "=BINOM.INV(6, 0.5)"); // args error
model.evaluate();
assert_eq!(model._get_text("A1"), *"4");
assert_eq!(model._get_text("A2"), *"#NUM!");
assert_eq!(model._get_text("A3"), *"#NUM!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
}
#[test]
fn test_fn_negbinom_dist_smoke() {
let mut model = new_empty_model();
// Valid: PMF (non-cumulative) and CDF (cumulative)
model._set("A1", "=NEGBINOM.DIST(10, 5, 0.25, FALSE)");
model._set("A2", "=NEGBINOM.DIST(10, 5, 0.25, TRUE)");
// Wrong number of arguments -> #ERROR!
model._set("A3", "=NEGBINOM.DIST(10, 5, 0.25)");
model._set("A4", "=NEGBINOM.DIST(10, 5, 0.25, TRUE, FALSE)");
// Domain errors:
// p < 0 or p > 1 -> #NUM!
model._set("A5", "=NEGBINOM.DIST(10, 5, 1.5, TRUE)");
// number_f < 0 -> #NUM!
model._set("A6", "=NEGBINOM.DIST(-1, 5, 0.25, TRUE)");
// number_s < 1 -> #NUM!
model._set("A7", "=NEGBINOM.DIST(10, 0, 0.25, TRUE)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.05504866");
assert_eq!(model._get_text("A2"), *"0.313514058");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"#NUM!");
}

View File

@@ -0,0 +1,140 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_chisq_dist_smoke() {
let mut model = new_empty_model();
// Valid: CDF
model._set("A1", "=CHISQ.DIST(0.5, 4, TRUE)");
// Valid: PDF
model._set("A2", "=CHISQ.DIST(0.5, 4, FALSE)");
// Valid: CDF with numeric cumulative (1 -> TRUE)
model._set("A3", "=CHISQ.DIST(0.5, 4, 1)");
// Wrong number of args -> #ERROR!
model._set("A4", "=CHISQ.DIST(0.5, 4)");
model._set("A5", "=CHISQ.DIST(0.5, 4, TRUE, FALSE)");
// Domain errors
// x < 0 -> #NUM!
model._set("A6", "=CHISQ.DIST(-1, 4, TRUE)");
// deg_freedom < 1 -> #NUM!
model._set("A7", "=CHISQ.DIST(0.5, 0, TRUE)");
model.evaluate();
// Values for df = 4
// CDF(0.5) ≈ 0.026499021, PDF(0.5) ≈ 0.097350098
assert_eq!(model._get_text("A1"), *"0.026499021");
assert_eq!(model._get_text("A2"), *"0.097350098");
assert_eq!(model._get_text("A3"), *"0.026499021");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"#NUM!");
}
#[test]
fn test_fn_chisq_dist_rt_smoke() {
let mut model = new_empty_model();
// Valid calls
model._set("A1", "=CHISQ.DIST.RT(0.5, 4)");
model._set("A2", "=CHISQ.DIST.RT(5, 4)");
// Too few / too many args -> #ERROR!
model._set("A3", "=CHISQ.DIST.RT(0.5)");
model._set("A4", "=CHISQ.DIST.RT(0.5, 4, 1)");
// Domain errors
// x < 0 -> #NUM!
model._set("A5", "=CHISQ.DIST.RT(-1, 4)");
// deg_freedom < 1 -> #NUM!
model._set("A6", "=CHISQ.DIST.RT(0.5, 0)");
model.evaluate();
// For df = 4:
// right tail at 0.5 ≈ 0.973500979
// right tail at 5.0 ≈ 0.287297495
assert_eq!(model._get_text("A1"), *"0.973500979");
assert_eq!(model._get_text("A2"), *"0.287297495");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
}
#[test]
fn test_fn_chisq_inv_smoke() {
let mut model = new_empty_model();
// Valid calls
model._set("A1", "=CHISQ.INV(0.95, 4)");
model._set("A2", "=CHISQ.INV(0.1, 10)");
// Wrong number of args -> #ERROR!
model._set("A3", "=CHISQ.INV(0.95)");
model._set("A4", "=CHISQ.INV(0.95, 4, 1)");
// Domain errors
// probability < 0 or > 1 -> #NUM!
model._set("A5", "=CHISQ.INV(-0.1, 4)");
model._set("A6", "=CHISQ.INV(1.1, 4)");
// deg_freedom < 1 -> #NUM!
model._set("A7", "=CHISQ.INV(0.5, 0)");
model.evaluate();
// Standard critical values:
// CHISQ.INV(0.95, 4) ≈ 9.487729037
// CHISQ.INV(0.1, 10) ≈ 4.865182052
assert_eq!(model._get_text("A1"), *"9.487729037");
assert_eq!(model._get_text("A2"), *"4.865182052");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"#NUM!");
}
#[test]
fn test_fn_chisq_inv_rt_smoke() {
let mut model = new_empty_model();
// Valid calls
model._set("A1", "=CHISQ.INV.RT(0.05, 4)");
model._set("A2", "=CHISQ.INV.RT(0.9, 10)");
// Wrong number of args -> #ERROR!
model._set("A3", "=CHISQ.INV.RT(0.05)");
model._set("A4", "=CHISQ.INV.RT(0.05, 4, 1)");
// Domain errors
// probability < 0 or > 1 -> #NUM!
model._set("A5", "=CHISQ.INV.RT(-0.1, 4)");
model._set("A6", "=CHISQ.INV.RT(1.1, 4)");
// deg_freedom < 1 -> #NUM!
model._set("A7", "=CHISQ.INV.RT(0.5, 0)");
model.evaluate();
// For chi-square:
// CHISQ.INV.RT(0.05, 4) = CHISQ.INV(0.95, 4) ≈ 9.487729037
// CHISQ.INV.RT(0.9, 10) = CHISQ.INV(0.1, 10) ≈ 4.865182052
assert_eq!(model._get_text("A1"), *"9.487729037");
assert_eq!(model._get_text("A2"), *"4.865182052");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"#NUM!");
}

View File

@@ -0,0 +1,127 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_chisq_test_smoke() {
let mut model = new_empty_model();
model._set("A2", "48");
model._set("A3", "32");
model._set("A4", "12");
model._set("A5", "1");
model._set("A6", "'13");
model._set("A7", "TRUE");
model._set("A8", "1");
model._set("A9", "13");
model._set("A10", "15");
model._set("B2", "55");
model._set("B3", "34");
model._set("B4", "13");
model._set("B5", "blah");
model._set("B6", "13");
model._set("B7", "1");
model._set("B8", "TRUE");
model._set("B9", "'14");
model._set("B10", "16");
model._set("C1", "=CHISQ.TEST(A2:A10, B2:B10)");
model.evaluate();
assert_eq!(model._get_text("C1"), *"0.997129538");
}
#[test]
fn arrays() {
let mut model = new_empty_model();
model._set("A2", "TRUE");
model._set("A3", "4");
model._set("A4", "'3");
model._set("B2", "2");
model._set("B3", "2");
model._set("B4", "2");
model._set("C1", "=CHISQ.TEST(A2:A4, B2:B4)");
model._set("G5", "=CHISQ.TEST({TRUE,4,\"3\"}, {2,2,2})");
// 1D arrays with different shapes
model._set("G6", "=CHISQ.TEST({1,2,3}, {3;3;4})");
// 2D array
model._set("G7", "=CHISQ.TEST({1,2;3,4},{2,3;2,2})");
// 1D arrays with same shape
model._set("G8", "=CHISQ.TEST({1,2,3,4}, {2,3,4,5})");
model.evaluate();
assert_eq!(model._get_text("C1"), *"0.367879441");
assert_eq!(model._get_text("G5"), *"0.367879441");
assert_eq!(model._get_text("G6"), *"0.383531573");
assert_eq!(model._get_text("G7"), *"0.067889155");
assert_eq!(model._get_text("G8"), *"0.733094495");
}
#[test]
fn more_arrays() {
let mut model = new_empty_model();
model._set("V20", "2");
model._set("V21", "4");
model._set("W20", "3");
model._set("W21", "5");
model._set("C1", "=CHISQ.TEST({1,2;3,4},V20:W21)");
model._set("C2", "=CHISQ.TEST({1,2;3,4}, {2,3;4,5})");
model.evaluate();
assert_eq!(model._get_text("C1"), *"0.257280177");
assert_eq!(model._get_text("C2"), *"0.257280177");
}
#[test]
fn array_ranges() {
let mut model = new_empty_model();
model._set("A2", "TRUE");
model._set("A3", "4");
model._set("A4", "'3");
model._set("B2", "2");
model._set("B3", "2");
model._set("B4", "2");
model._set("C1", "=CHISQ.TEST(A2:A4, {2;2;2})");
model._set("G5", "=CHISQ.TEST({TRUE;4;\"3\"}, B2:B4)");
model.evaluate();
assert_eq!(model._get_text("C1"), *"0.367879441");
assert_eq!(model._get_text("G5"), *"0.367879441");
}
#[test]
fn array_2d_ranges() {
let mut model = new_empty_model();
model._set("A2", "2");
model._set("B2", "3");
model._set("C2", "4");
model._set("A3", "5");
model._set("B3", "6");
model._set("C3", "7");
model._set("G1", "=CHISQ.TEST({1,2,3;4,2,6}, A2:C3)");
model.evaluate();
assert_eq!(model._get_text("G1"), *"0.129195493");
}
#[test]
fn ranges_1d() {
let mut model = new_empty_model();
model._set("A2", "1");
model._set("A3", "2");
model._set("A4", "3");
model._set("B2", "4");
model._set("C2", "5");
model._set("D2", "6");
model._set("G1", "=CHISQ.TEST(A2:A4, B2:D2)");
model._set("G2", "=CHISQ.TEST(B2:D2, A2:A4)");
model.evaluate();
assert_eq!(model._get_text("G1"), *"0.062349477");
assert_eq!(model._get_text("G2"), *"0.000261259");
}

View File

@@ -0,0 +1,51 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_confidence_norm_smoke() {
let mut model = new_empty_model();
model._set("A1", "=CONFIDENCE.NORM(0.05, 2.5, 50)");
// Some edge/error cases
model._set("A2", "=CONFIDENCE.NORM(0, 2.5, 50)"); // alpha <= 0 -> #NUM!
model._set("A3", "=CONFIDENCE.NORM(1, 2.5, 50)"); // alpha >= 1 -> #NUM!
model._set("A4", "=CONFIDENCE.NORM(0.05, -1, 50)"); // std_dev <=0 -> #NUM!
model._set("A5", "=CONFIDENCE.NORM(0.05, 2.5, 1)");
model._set("A6", "=CONFIDENCE.NORM(0.05, 2.5, 0.99)"); // size < 1 -> #NUM!
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.692951912");
assert_eq!(model._get_text("A2"), *"#NUM!");
assert_eq!(model._get_text("A3"), *"#NUM!");
assert_eq!(model._get_text("A4"), *"#NUM!");
assert_eq!(model._get_text("A5"), *"4.899909961");
assert_eq!(model._get_text("A6"), *"#NUM!");
}
#[test]
fn test_fn_confidence_t_smoke() {
let mut model = new_empty_model();
model._set("A1", "=CONFIDENCE.T(0.05, 50000, 100)");
// Some edge/error cases
model._set("A2", "=CONFIDENCE.T(0, 50000, 100)"); // alpha <= 0 -> #NUM!
model._set("A3", "=CONFIDENCE.T(1, 50000, 100)"); // alpha >= 1 -> #NUM!
model._set("A4", "=CONFIDENCE.T(0.05, -1, 100)");
model._set("A5", "=CONFIDENCE.T(0.05, 50000, 1)");
model._set("A6", "=CONFIDENCE.T(0.05, 50000, 1.7)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"9921.08475793");
assert_eq!(model._get_text("A2"), *"#NUM!");
assert_eq!(model._get_text("A3"), *"#NUM!");
assert_eq!(model._get_text("A4"), *"#NUM!");
assert_eq!(model._get_text("A5"), *"#DIV/0!");
assert_eq!(model._get_text("A6"), *"#DIV/0!");
}

View File

@@ -0,0 +1,57 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_covariance_smoke() {
let mut model = new_empty_model();
model._set("A1", "3");
model._set("A2", "9");
model._set("A3", "2");
model._set("A4", "7");
model._set("A5", "4");
model._set("A6", "12");
model._set("B1", "5");
model._set("B2", "15");
model._set("B3", "6");
model._set("B4", "17");
model._set("B5", "8");
model._set("B6", "20");
model._set("C1", "=COVARIANCE.P(A1:A6, B1:B6)");
model._set("C2", "=COVARIANCE.S(A1:A6, B1:B6)");
model.evaluate();
assert_eq!(model._get_text("C1"), *"19.194444444");
assert_eq!(model._get_text("C2"), *"23.033333333");
}
#[test]
fn arrays_mixed() {
let mut model = new_empty_model();
model._set("A2", "2");
model._set("A3", "4");
model._set("A4", "6");
model._set("A5", "8");
model._set("B2", "1");
model._set("B3", "3");
model._set("B4", "5");
model._set("B5", "7");
model._set("C1", "=COVARIANCE.P(A2:A5, {1,3,5,7})");
model._set("C2", "=COVARIANCE.S(A2:A5, {1,3,5,7})");
model._set("C3", "=COVARIANCE.P(A2:A5, B2:B5)");
model._set("C4", "=COVARIANCE.S(A2:A5, B2:B5)");
model._set("C5", "=COVARIANCE.P({2,4,6,8}, B2:B5)");
model._set("C6", "=COVARIANCE.S({2,4,6,8}, B2:B5)");
model._set("C7", "=COVARIANCE.P({2,4,6,8}, {1,3,5,7})");
model._set("C8", "=COVARIANCE.S({2,4,6,8}, {1,3,5,7})");
model.evaluate();
assert_eq!(model._get_text("C1"), *"5");
assert_eq!(model._get_text("C2"), *"6.666666667");
}

View File

@@ -0,0 +1,50 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn arguments_smoke_test() {
let mut model = new_empty_model();
model._set("A1", "=DEVSQ()");
model._set("A2", "=DEVSQ(1, 2, 3)");
model._set("A3", "=DEVSQ(1, )");
model._set("A4", "=DEVSQ(1, , 3)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"2");
assert_eq!(model._get_text("A3"), *"0");
assert_eq!(model._get_text("A4"), *"2");
}
#[test]
fn ranges() {
let mut model = new_empty_model();
model._set("A1", "=DEVSQ(A2:A8)");
model._set("A2", "4");
model._set("A3", "5");
model._set("A4", "8");
model._set("A5", "7");
model._set("A6", "11");
model._set("A7", "4");
model._set("A8", "3");
model.evaluate();
assert_eq!(model._get_text("A1"), *"48");
}
#[test]
fn arrays() {
let mut model = new_empty_model();
model._set("A1", "=DEVSQ({1, 2, 3})");
model._set("A2", "=DEVSQ({1; 2; 3})");
model._set("A3", "=DEVSQ({1, 2; 3, 4})");
model._set("A4", "=DEVSQ({1, 2; 3, 4; 5, 6})");
model.evaluate();
assert_eq!(model._get_text("A1"), *"2");
assert_eq!(model._get_text("A2"), *"2");
assert_eq!(model._get_text("A3"), *"5");
assert_eq!(model._get_text("A4"), *"17.5");
}

View File

@@ -0,0 +1,32 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_expon_dist_smoke() {
let mut model = new_empty_model();
// λ = 1, x = 0.5
// CDF = 1 - e^-0.5 ≈ 0.393469340
// PDF = e^-0.5 ≈ 0.606530660
model._set("A1", "=EXPON.DIST(0.5, 1, TRUE)");
model._set("A2", "=EXPON.DIST(0.5, 1, FALSE)");
// Wrong number of args
model._set("A3", "=EXPON.DIST(0.5, 1)");
model._set("A4", "=EXPON.DIST(0.5, 1, TRUE, FALSE)");
// Domain errors
model._set("A5", "=EXPON.DIST(-1, 1, TRUE)"); // x < 0
model._set("A6", "=EXPON.DIST(0.5, 0, TRUE)"); // lambda <= 0
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.39346934");
assert_eq!(model._get_text("A2"), *"0.60653066");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
}

View File

@@ -0,0 +1,75 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_f_dist_sanity() {
let mut model = new_empty_model();
model._set("A1", "=F.DIST(15, 6, 4, TRUE)");
model._set("A2", "=F.DIST(15, 6, 4, FALSE)");
model._set("A3", "=F.DIST(15, 6, 4)");
model._set("A4", "=F.DIST(15, 6, 4, TRUE, FALSE)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.989741952");
assert_eq!(model._get_text("A2"), *"0.001271447");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
}
#[test]
fn test_fn_f_dist_rt_sanity() {
let mut model = new_empty_model();
// Valid call
model._set("A1", "=F.DIST.RT(15, 6, 4)");
// Too few args
model._set("A2", "=F.DIST.RT(15, 6)");
// Too many args
model._set("A3", "=F.DIST.RT(15, 6, 4, 1)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.010258048");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
}
#[test]
fn test_fn_f_inv_sanity() {
let mut model = new_empty_model();
// Valid call: left-tail inverse
model._set("A1", "=F.INV(0.9897419523940, 6, 4)");
// Too many args
model._set("A2", "=F.INV(0.5, 6, 4, 2)");
// Too few args
model._set("A3", "=F.INV(0.5, 6)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"15");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
}
#[test]
fn test_fn_f_inv_rt_sanity() {
let mut model = new_empty_model();
// Valid call: left-tail inverse
model._set("A1", "=F.INV.RT(0.0102580476059808, 6, 4)");
// Too many args
model._set("A2", "=F.INV.RT(0.5, 6, 4, 2)");
// Too few args
model._set("A3", "=F.INV.RT(0.5, 6)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"15");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
}

View File

@@ -0,0 +1,53 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_fisher_smoke() {
let mut model = new_empty_model();
// Valid inputs
model._set("A1", "=FISHER(0.1)");
model._set("A2", "=FISHER(-0.5)");
model._set("A3", "=FISHER(0.8)");
// Domain errors: x <= -1 or x >= 1 -> #NUM!
model._set("A4", "=FISHER(1)");
model._set("A5", "=FISHER(-1)");
model._set("A6", "=FISHER(2)");
// Wrong number of arguments -> #ERROR!
model._set("A7", "=FISHER(0.1, 2)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.100335348");
assert_eq!(model._get_text("A2"), *"-0.549306144");
assert_eq!(model._get_text("A3"), *"1.098612289");
assert_eq!(model._get_text("A4"), *"#NUM!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"#ERROR!");
}
#[test]
fn test_fn_fisher_inv_smoke() {
let mut model = new_empty_model();
// Valid inputs
model._set("A1", "=FISHERINV(-1.5)");
model._set("A2", "=FISHERINV(0.5)");
model._set("A3", "=FISHERINV(2)");
// Wrong number of arguments -> #ERROR!
model._set("A4", "=FISHERINV(0.5, 1)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"-0.905148254");
assert_eq!(model._get_text("A2"), *"0.462117157");
assert_eq!(model._get_text("A3"), *"0.96402758");
assert_eq!(model._get_text("A4"), *"#ERROR!");
}

View File

@@ -0,0 +1,42 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_hyp_geom_dist_smoke() {
let mut model = new_empty_model();
// Valid: PDF (non-cumulative)
model._set("A1", "=HYPGEOM.DIST(1, 4, 12, 20, FALSE)");
// Valid: CDF (cumulative)
model._set("A2", "=HYPGEOM.DIST(1, 4, 12, 20, TRUE)");
// Wrong number of arguments -> #ERROR!
model._set("A3", "=HYPGEOM.DIST(1, 4, 12, 20)");
model._set("A4", "=HYPGEOM.DIST(1, 4, 12, 20, TRUE, FALSE)");
// Domain errors:
// sample_s > number_sample -> #NUM!
model._set("A5", "=HYPGEOM.DIST(5, 4, 12, 20, TRUE)");
// population_s > number_pop -> #NUM!
model._set("A6", "=HYPGEOM.DIST(1, 4, 25, 20, TRUE)");
// number_sample > number_pop -> #NUM!
model._set("A7", "=HYPGEOM.DIST(1, 25, 12, 20, TRUE)");
model.evaluate();
// PDF: P(X = 1)
assert_eq!(model._get_text("A1"), *"0.13869969");
// CDF: P(X <= 1)
assert_eq!(model._get_text("A2"), *"0.153147575");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"#NUM!");
}

View File

@@ -0,0 +1,61 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_log_norm_dist_smoke() {
let mut model = new_empty_model();
// Valid: CDF and PDF
model._set("A1", "=LOGNORM.DIST(4, 3.5, 1.2, TRUE)");
model._set("A2", "=LOGNORM.DIST(4, 3.5, 1.2, FALSE)");
// Wrong number of arguments -> #ERROR!
model._set("A3", "=LOGNORM.DIST(4, 3.5, 1.2)");
model._set("A4", "=LOGNORM.DIST(4, 3.5, 1.2, TRUE, FALSE)");
// Domain errors:
// x <= 0 -> #NUM!
model._set("A5", "=LOGNORM.DIST(0, 3.5, 1.2, TRUE)");
// std_dev <= 0 -> #NUM!
model._set("A6", "=LOGNORM.DIST(4, 3.5, 0, TRUE)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.039083556");
assert_eq!(model._get_text("A2"), *"0.017617597");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
}
#[test]
fn test_fn_log_norm_inv_smoke() {
let mut model = new_empty_model();
// Valid call
model._set("A1", "=LOGNORM.INV(0.5, 3.5, 1.2)");
// Wrong number of arguments -> #ERROR!
model._set("A2", "=LOGNORM.INV(0.5, 3.5)");
model._set("A3", "=LOGNORM.INV(0.5, 3.5, 1.2, 0)");
// Domain errors:
// probability <= 0 or >= 1 -> #NUM!
model._set("A4", "=LOGNORM.INV(0, 3.5, 1.2)");
model._set("A5", "=LOGNORM.INV(1, 3.5, 1.2)");
// std_dev <= 0 -> #NUM!
model._set("A6", "=LOGNORM.INV(0.5, 3.5, 0)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"33.115451959");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#NUM!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
}

View File

@@ -0,0 +1,119 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_norm_dist_smoke() {
let mut model = new_empty_model();
// Valid: standard normal as a special case
model._set("A1", "=NORM.DIST(1, 0, 1, TRUE)");
model._set("A2", "=NORM.DIST(1, 0, 1, FALSE)");
// Wrong number of arguments -> #ERROR!
model._set("A3", "=NORM.DIST(1, 0, 1)");
model._set("A4", "=NORM.DIST(1, 0, 1, TRUE, FALSE)");
// Domain errors: standard_dev <= 0 -> #NUM!
model._set("A5", "=NORM.DIST(1, 0, 0, TRUE)");
model._set("A6", "=NORM.DIST(1, 0, -1, TRUE)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.841344746");
assert_eq!(model._get_text("A2"), *"0.241970725");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
}
#[test]
fn test_fn_norm_inv_smoke() {
let mut model = new_empty_model();
// Valid: median of standard normal
model._set("A1", "=NORM.INV(0.5, 0, 1)");
// Wrong number of arguments -> #ERROR!
model._set("A2", "=NORM.INV(0.5, 0)");
model._set("A3", "=NORM.INV(0.5, 0, 1, 0)");
// Domain errors:
// probability <= 0 or >= 1 -> #NUM!
model._set("A4", "=NORM.INV(0, 0, 1)");
model._set("A5", "=NORM.INV(1, 0, 1)");
// standard_dev <= 0 -> #NUM!
model._set("A6", "=NORM.INV(0.5, 0, 0)");
model._set("A7", "=NORM.INV(0.7, 0.2, 1)");
model._set("A8", "=NORM.INV(0.7, 0.2, 5)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#NUM!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"0.724400513");
assert_eq!(model._get_text("A8"), *"2.822002564");
}
#[test]
fn test_fn_norm_s_dist_smoke() {
let mut model = new_empty_model();
// Valid: CDF and PDF at z = 0
model._set("A1", "=NORM.S.DIST(0, TRUE)");
model._set("A2", "=NORM.S.DIST(0, FALSE)");
// Wrong number of arguments -> #ERROR!
model._set("A3", "=NORM.S.DIST(0)");
model._set("A4", "=NORM.S.DIST(0, TRUE, FALSE)");
model._set("A5", "=NORM.S.DIST(0.2, FALSE)");
model._set("A6", "=NORM.S.DIST(2.2, TRUE)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.5");
assert_eq!(model._get_text("A2"), *"0.39894228");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"0.391042694");
assert_eq!(model._get_text("A6"), *"0.986096552");
}
#[test]
fn test_fn_norm_s_inv_smoke() {
let mut model = new_empty_model();
// Valid: symmetric points
model._set("A1", "=NORM.S.INV(0.5)");
model._set("A2", "=NORM.S.INV(0.841344746)");
// Wrong number of arguments -> #ERROR!
model._set("A3", "=NORM.S.INV()");
model._set("A4", "=NORM.S.INV(0.5, 0)");
// Domain errors: probability <= 0 or >= 1 -> #NUM!
model._set("A5", "=NORM.S.INV(0)");
model._set("A6", "=NORM.S.INV(1)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0");
// Approximately 1
assert_eq!(model._get_text("A2"), *"1");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
}

View File

@@ -0,0 +1,31 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_chisq_test_smoke() {
let mut model = new_empty_model();
model._set("A2", "48");
model._set("A3", "32");
model._set("A4", "12");
model._set("A5", "1");
model._set("A6", "'13");
model._set("A7", "TRUE");
model._set("A8", "1");
model._set("A9", "13");
model._set("A10", "15");
model._set("B2", "55");
model._set("B3", "34");
model._set("B4", "13");
model._set("B5", "blah");
model._set("B6", "13");
model._set("B7", "1");
model._set("B8", "TRUE");
model._set("B9", "'14");
model._set("B10", "16");
model._set("C1", "=PEARSON(A2:A10, B2:B10)");
model.evaluate();
assert_eq!(model._get_text("C1"), *"0.998381439");
}

View File

@@ -0,0 +1,26 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_phi_smoke() {
let mut model = new_empty_model();
model._set("A1", "=PHI(0)");
model._set("A2", "=PHI(1)");
model._set("A3", "=PHI(-1)");
// Wrong number of arguments -> #ERROR!
model._set("A4", "=PHI()");
model._set("A5", "=PHI(0, 1)");
model.evaluate();
// Standard values
assert_eq!(model._get_text("A1"), *"0.39894228");
assert_eq!(model._get_text("A2"), *"0.241970725");
assert_eq!(model._get_text("A3"), *"0.241970725");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#ERROR!");
}

View File

@@ -0,0 +1,41 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_poisson_dist_smoke() {
let mut model = new_empty_model();
// λ = 2, x = 3
// P(X = 3) ≈ 0.180447045
// P(X <= 3) ≈ 0.857123461
model._set("A1", "=POISSON.DIST(3, 2, FALSE)");
model._set("A2", "=POISSON.DIST(3, 2, TRUE)");
// Wrong arg count
model._set("A3", "=POISSON.DIST(3, 2)");
model._set("A4", "=POISSON.DIST(3, 2, TRUE, FALSE)");
// Domain errors
model._set("A5", "=POISSON.DIST(-1, 2, TRUE)"); // x < 0
model._set("A6", "=POISSON.DIST(3, -2, TRUE)"); // mean < 0
// λ = 0 special cases
model._set("A7", "=POISSON.DIST(0, 0, FALSE)"); // 1
model._set("A8", "=POISSON.DIST(1, 0, FALSE)"); // 0
model._set("A9", "=POISSON.DIST(5, 0, TRUE)"); // 1
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.180447044");
assert_eq!(model._get_text("A2"), *"0.85712346");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"1");
assert_eq!(model._get_text("A8"), *"0");
assert_eq!(model._get_text("A9"), *"1");
}

View File

@@ -0,0 +1,46 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn smoke_test() {
let mut model = new_empty_model();
model._set("A1", "=STDEV.P(10, 12, 23, 23, 16, 23, 21)");
model._set("A2", "=STDEV.S(10, 12, 23, 23, 16, 23, 21)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"5.174505793");
assert_eq!(model._get_text("A2"), *"5.589105048");
}
#[test]
fn numbers() {
let mut model = new_empty_model();
model._set("A2", "24");
model._set("A3", "25");
model._set("A4", "27");
model._set("A5", "23");
model._set("A6", "45");
model._set("A7", "23.5");
model._set("A8", "34");
model._set("A9", "23");
model._set("A10", "23");
model._set("A11", "TRUE");
model._set("A12", "'23");
model._set("A13", "Text");
model._set("A14", "FALSE");
model._set("A15", "45");
model._set("B1", "=STDEV.P(A2:A15)");
model._set("B2", "=STDEV.S(A2:A15)");
model._set("B3", "=STDEVA(A2:A15)");
model._set("B4", "=STDEVPA(A2:A15)");
model.evaluate();
assert_eq!(model._get_text("B1"), *"8.483071378");
assert_eq!(model._get_text("B2"), *"8.941942369");
assert_eq!(model._get_text("B3"), *"15.499955689");
assert_eq!(model._get_text("B4"), *"14.936131032");
}

View File

@@ -0,0 +1,160 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_t_dist_smoke() {
let mut model = new_empty_model();
// Valid: cumulative (left-tail CDF)
model._set("A1", "=T.DIST(2, 10, TRUE)");
// Valid: probability density function (PDF)
model._set("B1", "=T.DIST(2, 10, FALSE)");
// Wrong number of arguments
model._set("A2", "=T.DIST(2, 10)");
model._set("A3", "=T.DIST(2, 10, TRUE, FALSE)");
// Domain error: df < 1 -> #NUM!
model._set("A4", "=T.DIST(2, 0, TRUE)");
model._set("A5", "=T.DIST(2, -1, TRUE)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.963305983");
assert_eq!(model._get_text("B1"), *"0.061145766");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#NUM!");
assert_eq!(model._get_text("A5"), *"#NUM!");
}
#[test]
fn test_fn_t_dist_rt_smoke() {
let mut model = new_empty_model();
// Valid: right tail probability
model._set("A1", "=T.DIST.RT(2, 10)");
// Wrong number of arguments
model._set("A2", "=T.DIST.RT(2)");
model._set("A3", "=T.DIST.RT(2, 10, TRUE)");
// Domain error: df < 1
model._set("A4", "=T.DIST.RT(2, 0)");
model._set("A5", "=T.DIST.RT(2, -1)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.036694017");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#NUM!");
assert_eq!(model._get_text("A5"), *"#NUM!");
}
#[test]
fn test_fn_t_dist_2t_smoke() {
let mut model = new_empty_model();
// Valid: two-tailed probability
model._set("A1", "=T.DIST.2T(2, 10)");
// In the limit case of x = 0, the two-tailed probability is 1.0
model._set("A4", "=T.DIST.2T(0, 10)");
// Wrong number of arguments
model._set("A2", "=T.DIST.2T(2)");
model._set("A3", "=T.DIST.2T(2, 10, TRUE)");
// Domain errors:
// x < 0 -> #NUM!
model._set("A5", "=T.DIST.2T(-0.001, 10)");
// df < 1 -> #NUM!
model._set("A6", "=T.DIST.2T(2, 0)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0.073388035");
assert_eq!(model._get_text("A4"), *"1");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
}
#[test]
fn test_fn_t_inv_smoke() {
let mut model = new_empty_model();
// Valid: upper and lower tail
model._set("A1", "=T.INV(0.95, 10)");
model._set("A2", "=T.INV(0.05, 10)");
// limit case:
model._set("B2", "=T.INV(0.95, 1)");
// Wrong number of arguments
model._set("A3", "=T.INV(0.95)");
model._set("A4", "=T.INV(0.95, 10, 1)");
// Domain errors:
// p <= 0 or >= 1
model._set("A5", "=T.INV(0, 10)");
model._set("A6", "=T.INV(1, 10)");
// df < 1
model._set("A7", "=T.INV(0.95, 0)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"1.812461123");
assert_eq!(model._get_text("A2"), *"-1.812461123");
assert_eq!(model._get_text("B2"), *"6.313751515");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"#NUM!");
}
#[test]
fn test_fn_t_inv_2t_smoke() {
let mut model = new_empty_model();
// Valid: two-tailed critical values
model._set("A1", "=T.INV.2T(0.1, 10)");
model._set("A2", "=T.INV.2T(0.05, 10)");
// p = 1 should give t = 0 (both tails outside are 1.0, so cut at the mean)
model._set("A3", "=T.INV.2T(1, 10)");
model._set("A7", "=T.INV.2T(1.5, 10)");
// Wrong number of arguments
model._set("A4", "=T.INV.2T(0.1)");
model._set("A5", "=T.INV.2T(0.1, 10, 1)");
// Domain errors:
// p <= 0 or p > 1
model._set("A6", "=T.INV.2T(0, 10)");
// df < 1
model._set("A8", "=T.INV.2T(0.1, 0)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"1.812461123");
assert_eq!(model._get_text("A2"), *"2.228138852");
assert_eq!(model._get_text("A3"), *"0");
// NB: Excel returns -0.699812061 for T.INV.2T(1.5, 10)
// which seems inconsistent with its documented behavior
assert_eq!(model._get_text("A7"), *"#NUM!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A8"), *"#NUM!");
}

View File

@@ -0,0 +1,41 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_t_test_smoke() {
let mut model = new_empty_model();
model._set("A2", "3");
model._set("A3", "4");
model._set("A4", "5");
model._set("A5", "6");
model._set("A6", "10");
model._set("A7", "3");
model._set("A8", "2");
model._set("A9", "4");
model._set("A10", "7");
model._set("B2", "6");
model._set("B3", "19");
model._set("B4", "3");
model._set("B5", "2");
model._set("B6", "13");
model._set("B7", "4");
model._set("B8", "5");
model._set("B9", "17");
model._set("B10", "3");
model._set("C1", "=T.TEST(A2:A10, B2:B10, 1, 1)");
model._set("C2", "=T.TEST(A2:A10, B2:B10, 1, 2)");
model._set("C3", "=T.TEST(A2:A10, B2:B10, 1, 3)");
model._set("C4", "=T.TEST(A2:A10, B2:B10, 2, 1)");
model._set("C5", "=T.TEST(A2:A10, B2:B10, 2, 2)");
model._set("C6", "=T.TEST(A2:A10, B2:B10, 2, 3)");
model.evaluate();
assert_eq!(model._get_text("C1"), *"0.103836888");
assert_eq!(model._get_text("C2"), *"0.100244599");
assert_eq!(model._get_text("C3"), *"0.105360319");
assert_eq!(model._get_text("C4"), *"0.207673777");
assert_eq!(model._get_text("C5"), *"0.200489197");
assert_eq!(model._get_text("C6"), *"0.210720639");
}

View File

@@ -0,0 +1,46 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn smoke_test() {
let mut model = new_empty_model();
model._set("A1", "=STDEV.P(10, 12, 23, 23, 16, 23, 21)");
model._set("A2", "=STDEV.S(10, 12, 23, 23, 16, 23, 21)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"5.174505793");
assert_eq!(model._get_text("A2"), *"5.589105048");
}
#[test]
fn numbers() {
let mut model = new_empty_model();
model._set("A2", "24");
model._set("A3", "25");
model._set("A4", "27");
model._set("A5", "23");
model._set("A6", "45");
model._set("A7", "23.5");
model._set("A8", "34");
model._set("A9", "23");
model._set("A10", "23");
model._set("A11", "TRUE");
model._set("A12", "'23");
model._set("A13", "Text");
model._set("A14", "FALSE");
model._set("A15", "45");
model._set("B1", "=VAR.P(A2:A15)");
model._set("B2", "=VAR.S(A2:A15)");
model._set("B3", "=VARA(A2:A15)");
model._set("B4", "=VARPA(A2:A15)");
model.evaluate();
assert_eq!(model._get_text("B1"), *"71.9625");
assert_eq!(model._get_text("B2"), *"79.958333333");
assert_eq!(model._get_text("B3"), *"240.248626374");
assert_eq!(model._get_text("B4"), *"223.088010204");
}

View File

@@ -0,0 +1,41 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_weibull_dist_smoke() {
let mut model = new_empty_model();
// Valid: CDF and PDF for x = 1, alpha = 2, beta = 1
model._set("A1", "=WEIBULL.DIST(1, 2, 1, TRUE)");
model._set("A2", "=WEIBULL.DIST(1, 2, 1, FALSE)");
// Wrong number of arguments -> #ERROR!
model._set("A3", "=WEIBULL.DIST(1, 2, 1)");
model._set("A4", "=WEIBULL.DIST(1, 2, 1, TRUE, FALSE)");
// Domain errors:
// x < 0 -> #NUM!
model._set("A5", "=WEIBULL.DIST(-1, 2, 1, TRUE)");
// alpha <= 0 -> #NUM!
model._set("A6", "=WEIBULL.DIST(1, 0, 1, TRUE)");
model._set("A7", "=WEIBULL.DIST(1, -1, 1, TRUE)");
// beta <= 0 -> #NUM!
model._set("A8", "=WEIBULL.DIST(1, 2, 0, TRUE)");
model._set("A9", "=WEIBULL.DIST(1, 2, -1, TRUE)");
model.evaluate();
// 1 - e^-1
assert_eq!(model._get_text("A1"), *"0.632120559");
// 2 * e^-1
assert_eq!(model._get_text("A2"), *"0.735758882");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#NUM!");
assert_eq!(model._get_text("A6"), *"#NUM!");
assert_eq!(model._get_text("A7"), *"#NUM!");
assert_eq!(model._get_text("A8"), *"#NUM!");
assert_eq!(model._get_text("A9"), *"#NUM!");
}

View File

@@ -0,0 +1,36 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_z_test_smoke() {
let mut model = new_empty_model();
model._set("A2", "3");
model._set("A3", "6");
model._set("A4", "7");
model._set("A5", "8");
model._set("A6", "6");
model._set("A7", "5");
model._set("A8", "4");
model._set("A9", "2");
model._set("A10", "1");
model._set("A11", "9");
model._set("G1", "=Z.TEST(A2:A11, 4)");
model._set("G2", "=Z.TEST(A2:A11, 6)");
model.evaluate();
assert_eq!(model._get_text("G1"), *"0.090574197");
assert_eq!(model._get_text("G2"), *"0.863043389");
}
#[test]
fn arrays() {
let mut model = new_empty_model();
model._set("D1", "=Z.TEST({5,2,3,4}, 4, 123)");
model._set("D2", "=Z.TEST({5,2,3,4}, 4)");
model.evaluate();
assert_eq!(model._get_text("D1"), *"0.503243397");
assert_eq!(model._get_text("D2"), *"0.780710987");
}

View File

@@ -1,6 +1,6 @@
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]
use crate::constants::{DEFAULT_ROW_HEIGHT, LAST_COLUMN}; use crate::constants::{DEFAULT_ROW_HEIGHT, LAST_COLUMN, LAST_ROW};
use crate::model::Model; use crate::model::Model;
use crate::test::util::new_empty_model; use crate::test::util::new_empty_model;
use crate::types::Col; use crate::types::Col;
@@ -508,6 +508,10 @@ fn test_move_column_right() {
assert_eq!(model._get_formula("E5"), "=SUM(H3:J7)"); assert_eq!(model._get_formula("E5"), "=SUM(H3:J7)");
assert_eq!(model._get_formula("E6"), "=SUM(H3:H7)"); assert_eq!(model._get_formula("E6"), "=SUM(H3:H7)");
assert_eq!(model._get_formula("E7"), "=SUM(G3:G7)"); assert_eq!(model._get_formula("E7"), "=SUM(G3:G7)");
// Data moved as well
assert_eq!(model._get_text("G1"), "1");
assert_eq!(model._get_text("H1"), "3");
} }
#[test] #[test]
@@ -532,5 +536,249 @@ fn tets_move_column_error() {
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test]
fn test_move_row_down() {
let mut model = new_empty_model();
populate_table(&mut model);
// Formulas referencing rows 3 and 4
model._set("E3", "=G3");
model._set("E4", "=G4");
model._set("E5", "=SUM(G3:J3)");
model._set("E6", "=SUM(G3:G3)");
model._set("E7", "=SUM(G4:G4)");
model.evaluate();
// Move row 3 down by one position
let result = model.move_row_action(0, 3, 1);
assert!(result.is_ok());
model.evaluate();
assert_eq!(model._get_formula("E3"), "=G3");
assert_eq!(model._get_formula("E4"), "=G4");
assert_eq!(model._get_formula("E5"), "=SUM(G4:J4)");
assert_eq!(model._get_formula("E6"), "=SUM(G4:G4)");
assert_eq!(model._get_formula("E7"), "=SUM(G3:G3)");
// Data moved as well
assert_eq!(model._get_text("G4"), "-2");
assert_eq!(model._get_text("G3"), "");
}
#[test]
fn test_move_row_up() {
let mut model = new_empty_model();
populate_table(&mut model);
// Formulas referencing rows 4 and 5
model._set("E4", "=G4");
model._set("E5", "=G5");
model._set("E6", "=SUM(G4:J4)");
model._set("E7", "=SUM(G4:G4)");
model._set("E8", "=SUM(G5:G5)");
model.evaluate();
// Move row 5 up by one position
let result = model.move_row_action(0, 5, -1);
assert!(result.is_ok());
model.evaluate();
assert_eq!(model._get_formula("E4"), "=G4");
assert_eq!(model._get_formula("E5"), "=G5");
assert_eq!(model._get_formula("E6"), "=SUM(G5:J5)");
assert_eq!(model._get_formula("E7"), "=SUM(G5:G5)");
assert_eq!(model._get_formula("E8"), "=SUM(G4:G4)");
// Data moved as well
assert_eq!(model._get_text("G4"), "");
assert_eq!(model._get_text("G5"), "");
}
#[test]
fn test_move_row_error() {
let mut model = new_empty_model();
model.evaluate();
let result = model.move_row_action(0, 7, -10);
assert!(result.is_err());
let result = model.move_row_action(0, -7, 20);
assert!(result.is_err());
let result = model.move_row_action(0, LAST_ROW, 1);
assert!(result.is_err());
let result = model.move_row_action(0, LAST_ROW + 1, -10);
assert!(result.is_err());
// This works
let result = model.move_row_action(0, LAST_ROW, -1);
assert!(result.is_ok());
}
#[test]
fn test_move_row_down_absolute_refs() {
let mut model = new_empty_model();
populate_table(&mut model);
// Absolute references
model._set("E3", "=$G$3");
model._set("E4", "=$G$4");
model._set("E5", "=SUM($G$3:$J$3)");
model._set("E6", "=SUM($G$3:$G$3)");
model._set("E7", "=SUM($G$4:$G$4)");
model.evaluate();
assert!(model.move_row_action(0, 3, 1).is_ok());
model.evaluate();
assert_eq!(model._get_formula("E3"), "=$G$3");
assert_eq!(model._get_formula("E4"), "=$G$4");
assert_eq!(model._get_formula("E5"), "=SUM($G$4:$J$4)");
assert_eq!(model._get_formula("E6"), "=SUM($G$4:$G$4)");
assert_eq!(model._get_formula("E7"), "=SUM($G$3:$G$3)");
}
#[test]
fn test_move_column_right_absolute_refs() {
let mut model = new_empty_model();
populate_table(&mut model);
// Absolute references
model._set("E3", "=$G$3");
model._set("E4", "=$H$3");
model._set("E5", "=SUM($G$3:$J$7)");
model._set("E6", "=SUM($G$3:$G$7)");
model._set("E7", "=SUM($H$3:$H$7)");
model.evaluate();
assert!(model.move_column_action(0, 7, 1).is_ok());
model.evaluate();
assert_eq!(model._get_formula("E3"), "=$H$3");
assert_eq!(model._get_formula("E4"), "=$G$3");
assert_eq!(model._get_formula("E5"), "=SUM($H$3:$J$7)");
assert_eq!(model._get_formula("E6"), "=SUM($H$3:$H$7)");
assert_eq!(model._get_formula("E7"), "=SUM($G$3:$G$7)");
}
#[test]
fn test_move_row_down_mixed_refs() {
let mut model = new_empty_model();
populate_table(&mut model);
model._set("E3", "=$G3"); // absolute col, relative row
model._set("E4", "=$G4");
model._set("E5", "=SUM($G3:$J3)");
model._set("E6", "=SUM($G3:$G3)");
model._set("E7", "=SUM($G4:$G4)");
model._set("F3", "=H$3"); // relative col, absolute row
model._set("F4", "=G$3");
model.evaluate();
assert!(model.move_row_action(0, 3, 1).is_ok());
model.evaluate();
assert_eq!(model._get_formula("E3"), "=$G3");
assert_eq!(model._get_formula("E4"), "=$G4");
assert_eq!(model._get_formula("E5"), "=SUM($G4:$J4)");
assert_eq!(model._get_formula("E6"), "=SUM($G4:$G4)");
assert_eq!(model._get_formula("E7"), "=SUM($G3:$G3)");
assert_eq!(model._get_formula("F3"), "=G$4");
assert_eq!(model._get_formula("F4"), "=H$4");
}
#[test]
fn test_move_column_right_mixed_refs() {
let mut model = new_empty_model();
populate_table(&mut model);
model._set("E3", "=$G3");
model._set("E4", "=$H3");
model._set("E5", "=SUM($G3:$J7)");
model._set("E6", "=SUM($G3:$G7)");
model._set("E7", "=SUM($H3:$H7)");
model._set("F3", "=H$3");
model._set("F4", "=H$3");
model.evaluate();
assert!(model.move_column_action(0, 7, 1).is_ok());
model.evaluate();
assert_eq!(model._get_formula("E3"), "=$H3");
assert_eq!(model._get_formula("E4"), "=$G3");
assert_eq!(model._get_formula("E5"), "=SUM($H3:$J7)");
assert_eq!(model._get_formula("E6"), "=SUM($H3:$H7)");
assert_eq!(model._get_formula("E7"), "=SUM($G3:$G7)");
assert_eq!(model._get_formula("F3"), "=G$3");
assert_eq!(model._get_formula("F4"), "=G$3");
}
#[test]
fn test_move_row_height() {
let mut model = new_empty_model();
let sheet = 0;
let custom_height = DEFAULT_ROW_HEIGHT * 2.0;
// Set a custom height for row 3
model
.workbook
.worksheet_mut(sheet)
.unwrap()
.set_row_height(3, custom_height)
.unwrap();
// Record the original height of row 4 (should be the default)
let original_row4_height = model.get_row_height(sheet, 4).unwrap();
// Move row 3 down by one position
assert!(model.move_row_action(sheet, 3, 1).is_ok());
// The custom height should now be on row 4
assert_eq!(model.get_row_height(sheet, 4), Ok(custom_height));
// Row 3 should now have the previous height of row 4
assert_eq!(model.get_row_height(sheet, 3), Ok(original_row4_height));
}
/// Moving a row down by two positions should shift formulas on intermediate
/// rows by only one (the row that gets skipped), not by the full delta this
/// guards against the regression fixed in the RowMove displacement logic.
#[test]
fn test_row_move_down_two_updates_intermediate_refs_by_one() {
let mut model = new_empty_model();
populate_table(&mut model);
// Set up formulas to verify intermediate rows shift by 1 (not full delta).
model._set("E3", "=G3"); // target row
model._set("E4", "=G4"); // intermediate row
model._set("E5", "=SUM(G3:J3)");
model.evaluate();
// Move row 3 down by two positions (row 3 -> row 5)
assert!(model.move_row_action(0, 3, 2).is_ok());
model.evaluate();
// Assert that references for the moved row and intermediate row are correct.
assert_eq!(model._get_formula("E3"), "=G3");
assert_eq!(model._get_formula("E5"), "=G5");
assert_eq!(model._get_formula("E4"), "=SUM(G5:J5)");
}
/// Moving a column right by two positions should shift formulas on
/// intermediate columns by only one, ensuring the ColumnMove displacement
/// logic handles multi-position moves correctly.
#[test]
fn test_column_move_right_two_updates_intermediate_refs_by_one() {
let mut model = new_empty_model();
populate_table(&mut model);
// Set up formulas to verify intermediate columns shift by 1 (not full delta).
model._set("E3", "=$G3"); // target column
model._set("E4", "=$H3"); // intermediate column
model._set("E5", "=SUM($G3:$J7)");
model.evaluate();
// Move column G (7) right by two positions (G -> I)
assert!(model.move_column_action(0, 7, 2).is_ok());
model.evaluate();
// Assert that references for moved and intermediate columns are correct.
assert_eq!(model._get_formula("E3"), "=$I3");
assert_eq!(model._get_formula("E4"), "=$G3");
assert_eq!(model._get_formula("E5"), "=SUM($I3:$J7)");
}
// A B C D E F G H I J K L M N O P Q R // A B C D E F G H I J K L M N O P Q R
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18

View File

@@ -0,0 +1,27 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn arguments() {
let mut model = new_empty_model();
model._set("A1", "=ARABIC()");
model._set("A2", "=ARABIC(V)");
model._set("A3", "=ARABIC(V, 2)");
model._set("A4", "=ROMAN()");
model._set("A5", "=ROMAN(5)");
model._set("A6", "=ROMAN(5, 0)");
model._set("A7", "=ROMAN(5, 0, 2)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"5");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"V");
assert_eq!(model._get_text("A6"), *"V");
assert_eq!(model._get_text("A7"), *"#ERROR!");
}

View File

@@ -0,0 +1,27 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn arguments() {
let mut model = new_empty_model();
model._set("A1", "=COMBIN(5,2)");
model._set("A2", "=COMBINA(5,2)");
model._set("A3", "=COMBIN()");
model._set("A4", "=COMBINA()");
model._set("A5", "=COMBIN(2)");
model._set("A6", "=COMBINA(2)");
model._set("A7", "=COMBIN(1, 2, 3)");
model._set("A8", "=COMBINA(1, 2, 3)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"10");
assert_eq!(model._get_text("A2"), *"15");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#ERROR!");
assert_eq!(model._get_text("A7"), *"#ERROR!");
assert_eq!(model._get_text("A8"), *"#ERROR!");
}

View File

@@ -6,6 +6,11 @@
/// We can also enter examples that illustrate/document a part of the function /// We can also enter examples that illustrate/document a part of the function
use crate::{cell::CellValue, test::util::new_empty_model}; use crate::{cell::CellValue, test::util::new_empty_model};
// Excel uses a serial date system where Jan 1, 1900 = 1 (though it treats 1900 as a leap year)
// Most test dates are documented inline, but we define boundary values here:
const EXCEL_MAX_DATE: f64 = 2958465.0; // Dec 31, 9999 - used in boundary tests
const EXCEL_INVALID_DATE: f64 = 2958466.0; // One day past max - used in error tests
#[test] #[test]
fn test_fn_date_arguments() { fn test_fn_date_arguments() {
let mut model = new_empty_model(); let mut model = new_empty_model();
@@ -216,3 +221,380 @@ fn test_date_early_dates() {
Ok(CellValue::Number(61.0)) Ok(CellValue::Number(61.0))
); );
} }
#[test]
fn test_days_function() {
let mut model = new_empty_model();
// Basic functionality
model._set("A1", "=DAYS(44570,44561)");
model._set("A2", "=DAYS(44561,44570)"); // Reversed order
model._set("A3", "=DAYS(44561,44561)");
// Edge cases
model._set("A4", "=DAYS(1,2)"); // Early dates
model._set(
"A5",
&format!("=DAYS({},{})", EXCEL_MAX_DATE, EXCEL_MAX_DATE - 1.0),
); // Near max date
// Error cases - wrong argument count
model._set("A6", "=DAYS()");
model._set("A7", "=DAYS(44561)");
model._set("A8", "=DAYS(44561,44570,1)");
// Error cases - invalid dates
model._set("A9", "=DAYS(-1,44561)");
model._set("A10", &format!("=DAYS(44561,{EXCEL_INVALID_DATE})"));
model.evaluate();
assert_eq!(model._get_text("A1"), *"9");
assert_eq!(model._get_text("A2"), *"-9");
assert_eq!(model._get_text("A3"), *"0");
assert_eq!(model._get_text("A4"), *"-1"); // DAYS(1,2) = 1-2 = -1
assert_eq!(model._get_text("A5"), *"1");
assert_eq!(model._get_text("A6"), *"#ERROR!");
assert_eq!(model._get_text("A7"), *"#ERROR!");
assert_eq!(model._get_text("A8"), *"#ERROR!");
assert_eq!(model._get_text("A9"), *"#NUM!");
assert_eq!(model._get_text("A10"), *"#NUM!");
}
#[test]
fn test_days360_function() {
let mut model = new_empty_model();
// Basic functionality with different basis values
model._set("A1", "=DAYS360(44196,44560)"); // Default basis (US 30/360)
model._set("A2", "=DAYS360(44196,44560,FALSE)"); // US 30/360 explicitly
model._set("A3", "=DAYS360(44196,44560,TRUE)"); // European 30/360
// Same date
model._set("A4", "=DAYS360(44561,44561)");
model._set("A5", "=DAYS360(44561,44561,TRUE)");
// Reverse order (negative result)
model._set("A6", "=DAYS360(44560,44196)");
model._set("A7", "=DAYS360(44560,44196,TRUE)");
// Edge cases
model._set("A8", "=DAYS360(1,2)");
model._set("A9", "=DAYS360(1,2,FALSE)");
// Error cases - wrong argument count
model._set("A10", "=DAYS360()");
model._set("A11", "=DAYS360(44561)");
model._set("A12", "=DAYS360(44561,44570,TRUE,1)");
// Error cases - invalid dates
model._set("A13", "=DAYS360(-1,44561)");
model._set("A14", &format!("=DAYS360(44561,{EXCEL_INVALID_DATE})"));
model.evaluate();
assert_eq!(model._get_text("A1"), *"360");
assert_eq!(model._get_text("A2"), *"360");
assert_eq!(model._get_text("A3"), *"360");
assert_eq!(model._get_text("A4"), *"0");
assert_eq!(model._get_text("A5"), *"0");
assert_eq!(model._get_text("A6"), *"-360");
assert_eq!(model._get_text("A7"), *"-360");
assert_eq!(model._get_text("A8"), *"1");
assert_eq!(model._get_text("A9"), *"1");
assert_eq!(model._get_text("A10"), *"#ERROR!");
assert_eq!(model._get_text("A11"), *"#ERROR!");
assert_eq!(model._get_text("A12"), *"#ERROR!");
assert_eq!(model._get_text("A13"), *"#NUM!");
assert_eq!(model._get_text("A14"), *"#NUM!");
}
#[test]
fn test_weekday_function() {
let mut model = new_empty_model();
// Test return_type parameter variations with one known date (Friday 44561)
model._set("A1", "=WEEKDAY(44561)"); // Default: Sun=1, Fri=6
model._set("A2", "=WEEKDAY(44561,2)"); // Mon=1, Fri=5
model._set("A3", "=WEEKDAY(44561,3)"); // Mon=0, Fri=4
// Test boundary days (Sun/Mon) to verify return_type logic
model._set("A4", "=WEEKDAY(44556,1)"); // Sunday: should be 1
model._set("A5", "=WEEKDAY(44556,2)"); // Sunday: should be 7
model._set("A6", "=WEEKDAY(44557,2)"); // Monday: should be 1
// Error cases
model._set("A7", "=WEEKDAY()"); // Wrong arg count
model._set("A8", "=WEEKDAY(44561,0)"); // Invalid return_type
model._set("A9", "=WEEKDAY(-1)"); // Invalid date
model.evaluate();
// Core functionality
assert_eq!(model._get_text("A1"), *"6"); // Friday default
assert_eq!(model._get_text("A2"), *"5"); // Friday Mon=1
assert_eq!(model._get_text("A3"), *"4"); // Friday Mon=0
// Boundary verification
assert_eq!(model._get_text("A4"), *"1"); // Sunday Sun=1
assert_eq!(model._get_text("A5"), *"7"); // Sunday Mon=1
assert_eq!(model._get_text("A6"), *"1"); // Monday Mon=1
// Error cases
assert_eq!(model._get_text("A7"), *"#ERROR!");
assert_eq!(model._get_text("A8"), *"#VALUE!");
assert_eq!(model._get_text("A9"), *"#NUM!");
}
#[test]
fn test_weeknum_function() {
let mut model = new_empty_model();
// Test different return_type values (1=week starts Sunday, 2=week starts Monday)
model._set("A1", "=WEEKNUM(44561)"); // Default return_type=1
model._set("A2", "=WEEKNUM(44561,1)"); // Sunday start
model._set("A3", "=WEEKNUM(44561,2)"); // Monday start
// Test year boundaries
model._set("A4", "=WEEKNUM(43831,1)"); // Jan 1, 2020 (Wednesday)
model._set("A5", "=WEEKNUM(43831,2)"); // Jan 1, 2020 (Wednesday)
model._set("A6", "=WEEKNUM(44196,1)"); // Dec 31, 2020 (Thursday)
model._set("A7", "=WEEKNUM(44196,2)"); // Dec 31, 2020 (Thursday)
// Test first and last weeks of year
model._set("A8", "=WEEKNUM(44197,1)"); // Jan 1, 2021 (Friday)
model._set("A9", "=WEEKNUM(44197,2)"); // Jan 1, 2021 (Friday)
model._set("A10", "=WEEKNUM(44561,1)"); // Dec 31, 2021 (Friday)
model._set("A11", "=WEEKNUM(44561,2)"); // Dec 31, 2021 (Friday)
// Error cases - wrong argument count
model._set("A12", "=WEEKNUM()");
model._set("A13", "=WEEKNUM(44561,1,1)");
// Error cases - invalid return_type
model._set("A14", "=WEEKNUM(44561,0)");
model._set("A15", "=WEEKNUM(44561,3)");
model._set("A16", "=WEEKNUM(44561,-1)");
// Error cases - invalid dates
model._set("A17", "=WEEKNUM(-1)");
model._set("A18", &format!("=WEEKNUM({EXCEL_INVALID_DATE})"));
model.evaluate();
// Basic functionality
assert_eq!(model._get_text("A1"), *"53"); // Week 53
assert_eq!(model._get_text("A2"), *"53"); // Week 53 (Sunday start)
assert_eq!(model._get_text("A3"), *"53"); // Week 53 (Monday start)
// Year boundary tests
assert_eq!(model._get_text("A4"), *"1"); // Jan 1, 2020 (Sunday start)
assert_eq!(model._get_text("A5"), *"1"); // Jan 1, 2020 (Monday start)
assert_eq!(model._get_text("A6"), *"53"); // Dec 31, 2020 (Sunday start)
assert_eq!(model._get_text("A7"), *"53"); // Dec 31, 2020 (Monday start)
// 2021 tests
assert_eq!(model._get_text("A8"), *"1"); // Jan 1, 2021 (Sunday start)
assert_eq!(model._get_text("A9"), *"1"); // Jan 1, 2021 (Monday start)
assert_eq!(model._get_text("A10"), *"53"); // Dec 31, 2021 (Sunday start)
assert_eq!(model._get_text("A11"), *"53"); // Dec 31, 2021 (Monday start)
// Error cases
assert_eq!(model._get_text("A12"), *"#ERROR!");
assert_eq!(model._get_text("A13"), *"#ERROR!");
assert_eq!(model._get_text("A14"), *"#VALUE!");
assert_eq!(model._get_text("A15"), *"#VALUE!");
assert_eq!(model._get_text("A16"), *"#VALUE!");
assert_eq!(model._get_text("A17"), *"#NUM!");
assert_eq!(model._get_text("A18"), *"#NUM!");
}
#[test]
fn test_workday_function() {
let mut model = new_empty_model();
// Basic functionality
model._set("A1", "=WORKDAY(44560,1)");
model._set("A2", "=WORKDAY(44561,-1)");
model._set("A3", "=WORKDAY(44561,0)");
model._set("A4", "=WORKDAY(44560,5)");
// Test with holidays
model._set("B1", "44561");
model._set("A5", "=WORKDAY(44560,1,B1)"); // Should skip the holiday
model._set("B2", "44562");
model._set("B3", "44563");
model._set("A6", "=WORKDAY(44560,3,B1:B3)"); // Multiple holidays
// Test starting on weekend
model._set("A7", "=WORKDAY(44562,1)"); // Saturday start
model._set("A8", "=WORKDAY(44563,1)"); // Sunday start
// Test negative workdays
model._set("A9", "=WORKDAY(44565,-3)"); // Go backwards 3 days
model._set("A10", "=WORKDAY(44565,-5,B1:B3)"); // Backwards with holidays
// Edge cases
model._set("A11", "=WORKDAY(1,1)"); // Early date
model._set("A12", "=WORKDAY(100000,10)"); // Large numbers
// Error cases - wrong argument count
model._set("A13", "=WORKDAY()");
model._set("A14", "=WORKDAY(44560)");
model._set("A15", "=WORKDAY(44560,1,B1,B2)");
// Error cases - invalid dates
model._set("A16", "=WORKDAY(-1,1)");
model._set("A17", &format!("=WORKDAY({EXCEL_INVALID_DATE},1)"));
// Error cases - invalid holiday dates
model._set("B4", "-1");
model._set("A18", "=WORKDAY(44560,1,B4)");
model.evaluate();
// Basic functionality
assert_eq!(model._get_text("A1"), *"44561"); // 1 day forward
assert_eq!(model._get_text("A2"), *"44560"); // 1 day backward
assert_eq!(model._get_text("A3"), *"44561"); // 0 days
assert_eq!(model._get_text("A4"), *"44567"); // 5 days forward
// With holidays
assert_eq!(model._get_text("A5"), *"44564"); // Skip holiday, go to Monday
assert_eq!(model._get_text("A6"), *"44566"); // Skip multiple holidays
// Weekend starts
assert_eq!(model._get_text("A7"), *"44564"); // From Saturday
assert_eq!(model._get_text("A8"), *"44564"); // From Sunday
// Negative workdays
assert_eq!(model._get_text("A9"), *"44560"); // 3 days back
assert_eq!(model._get_text("A10"), *"44557"); // 5 days back with holidays
// Edge cases
assert_eq!(model._get_text("A11"), *"2"); // Early date
assert_eq!(model._get_text("A12"), *"100014"); // Large numbers
// Error cases
assert_eq!(model._get_text("A13"), *"#ERROR!");
assert_eq!(model._get_text("A14"), *"#ERROR!");
assert_eq!(model._get_text("A15"), *"#ERROR!");
assert_eq!(model._get_text("A16"), *"#NUM!");
assert_eq!(model._get_text("A17"), *"#NUM!");
assert_eq!(model._get_text("A18"), *"#NUM!"); // Invalid holiday
}
#[test]
fn test_workday_intl_function() {
let mut model = new_empty_model();
// Test key weekend mask types
model._set("A1", "=WORKDAY.INTL(44560,1,1)"); // Numeric: standard (Sat-Sun)
model._set("A2", "=WORKDAY.INTL(44560,1,2)"); // Numeric: Sun-Mon
model._set("A3", "=WORKDAY.INTL(44560,1,\"0000001\")"); // String: Sunday only
model._set("A4", "=WORKDAY.INTL(44560,1,\"1100000\")"); // String: Mon-Tue
// Test with holidays
model._set("B1", "44561");
model._set("A5", "=WORKDAY.INTL(44560,2,1,B1)"); // Standard + holiday
model._set("A6", "=WORKDAY.INTL(44560,2,7,B1)"); // Fri-Sat + holiday
// Basic edge cases
model._set("A7", "=WORKDAY.INTL(44561,0,1)"); // Zero days
model._set("A8", "=WORKDAY.INTL(44565,-1,1)"); // Negative days
// Error cases
model._set("A9", "=WORKDAY.INTL()"); // Wrong arg count
model._set("A10", "=WORKDAY.INTL(44560,1,0)"); // Invalid weekend mask
model._set("A11", "=WORKDAY.INTL(44560,1,\"123\")"); // Invalid string mask
model._set("A12", "=WORKDAY.INTL(-1,1,1)"); // Invalid date
model.evaluate();
// Weekend mask functionality
assert_eq!(model._get_text("A1"), *"44561"); // Standard weekend
assert_eq!(model._get_text("A2"), *"44561"); // Sun-Mon weekend
assert_eq!(model._get_text("A3"), *"44561"); // Sunday only
assert_eq!(model._get_text("A4"), *"44561"); // Mon-Tue weekend
// With holidays
assert_eq!(model._get_text("A5"), *"44565"); // Skip holiday + standard weekend
assert_eq!(model._get_text("A6"), *"44564"); // Skip holiday + Fri-Sat weekend
// Edge cases
assert_eq!(model._get_text("A7"), *"44561"); // Zero days
assert_eq!(model._get_text("A8"), *"44564"); // Negative days
// Error cases
assert_eq!(model._get_text("A9"), *"#ERROR!");
assert_eq!(model._get_text("A10"), *"#NUM!");
assert_eq!(model._get_text("A11"), *"#VALUE!");
assert_eq!(model._get_text("A12"), *"#NUM!");
}
#[test]
fn test_yearfrac_function() {
let mut model = new_empty_model();
// Test key basis values (not exhaustive - just verify parameter works)
model._set("A1", "=YEARFRAC(44561,44926)"); // Default (30/360)
model._set("A2", "=YEARFRAC(44561,44926,1)"); // Actual/actual
model._set("A3", "=YEARFRAC(44561,44926,4)"); // European 30/360
// Edge cases
model._set("A4", "=YEARFRAC(44561,44561,1)"); // Same date = 0
model._set("A6", "=YEARFRAC(44197,44562,1)"); // Exact year (2021)
// Error cases
model._set("A7", "=YEARFRAC()"); // Wrong arg count
model._set("A8", "=YEARFRAC(44561,44926,5)"); // Invalid basis
model._set("A9", "=YEARFRAC(-1,44926,1)"); // Invalid date
model.evaluate();
// Basic functionality (approximate values expected)
assert_eq!(model._get_text("A1"), *"1"); // About 1 year
assert_eq!(model._get_text("A2"), *"1"); // About 1 year
assert_eq!(model._get_text("A3"), *"1"); // About 1 year
// Edge cases
assert_eq!(model._get_text("A4"), *"0"); // Same date
assert_eq!(model._get_text("A6"), *"1"); // Exact year
// Error cases
assert_eq!(model._get_text("A7"), *"#ERROR!");
assert_eq!(model._get_text("A8"), *"#NUM!"); // Invalid basis should return #NUM!
assert_eq!(model._get_text("A9"), *"#NUM!");
}
#[test]
fn test_isoweeknum_function() {
let mut model = new_empty_model();
// Basic functionality
model._set("A1", "=ISOWEEKNUM(44563)"); // Mid-week date
model._set("A2", "=ISOWEEKNUM(44561)"); // Year-end date
// Key ISO week boundaries (just critical cases)
model._set("A3", "=ISOWEEKNUM(44197)"); // Jan 1, 2021 (Fri) -> Week 53 of 2020
model._set("A4", "=ISOWEEKNUM(44200)"); // Jan 4, 2021 (Mon) -> Week 1 of 2021
model._set("A5", "=ISOWEEKNUM(44564)"); // Jan 3, 2022 (Mon) -> Week 1 of 2022
// Error cases
model._set("A6", "=ISOWEEKNUM()"); // Wrong arg count
model._set("A7", "=ISOWEEKNUM(-1)"); // Invalid date
model.evaluate();
// Basic functionality
assert_eq!(model._get_text("A1"), *"52");
assert_eq!(model._get_text("A2"), *"52");
// ISO week boundaries
assert_eq!(model._get_text("A3"), *"53"); // Week 53 of previous year
assert_eq!(model._get_text("A4"), *"1"); // Week 1 of current year
assert_eq!(model._get_text("A5"), *"1"); // Week 1 of next year
// Error cases
assert_eq!(model._get_text("A6"), *"#ERROR!");
assert_eq!(model._get_text("A7"), *"#NUM!");
}

View File

@@ -0,0 +1,33 @@
use crate::test::util::new_empty_model;
#[test]
fn test_datedif_yd_leap_year_edge_cases() {
let mut model = new_empty_model();
// 29 Feb 2020 → 28 Feb 2021 (should be 0 days)
model._set("A1", "=DATEDIF(\"29/2/2020\", \"28/2/2021\", \"YD\")");
// 29 Feb 2020 → 1 Mar 2021 (should be 1 day)
model._set("A2", "=DATEDIF(\"29/2/2020\", \"2021-03-01\", \"YD\")");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0");
assert_eq!(model._get_text("A2"), *"1");
}
#[test]
fn test_datedif_md_month_end_edge_cases() {
let mut model = new_empty_model();
// 31 Jan 2021 → 28 Feb 2021 (non-leap) => 28
model._set("B1", "=DATEDIF(\"31/1/2021\", \"28/2/2021\", \"MD\")");
// 31 Jan 2020 → 29 Feb 2020 (leap) => 29
model._set("B2", "=DATEDIF(\"31/1/2020\", \"29/2/2020\", \"MD\")");
model.evaluate();
assert_eq!(model._get_text("B1"), *"28");
assert_eq!(model._get_text("B2"), *"29");
}

Some files were not shown because too many files have changed in this diff Show More