From 6a8fecd03e335f4df40ed3eaa19f7faead6834f9 Mon Sep 17 00:00:00 2001 From: "C. Cassel" Date: Tue, 17 Mar 2026 07:54:17 -0400 Subject: [PATCH] feat(engine): establish calcpad-engine workspace with Epic 1 modules Cherry-picked and integrated the best code from 105 parallel epic branches into a clean workspace structure: - calcpad-engine/: Core Rust crate with lexer, parser, AST, interpreter, types, FFI (C ABI), pipeline, error handling, span tracking, eval context (from epic/1-5 base) - calcpad-engine/src/number.rs: Arbitrary precision arithmetic via dashu crate, WASM-compatible, exact decimals (from epic/1-4) - calcpad-engine/src/sheet_context.rs: SheetContext with dependency graph, dirty tracking, circular detection, cheap clone (from epic/1-8) - calcpad-wasm/: Thin WASM wrapper crate via wasm-bindgen, delegates to calcpad-engine (from epic/1-6) - Updated .gitignore for target/, node_modules/, build artifacts - Fixed run-pipeline.sh for macOS compat and CalcPad phases 79 tests passing across workspace. --- .gitignore | 40 ++ Cargo.lock | 656 +++++++++++++++++++++++++ Cargo.toml | 13 +- calcpad-engine/Cargo.toml | 13 + calcpad-engine/include/calcpad.h | 90 ++++ calcpad-engine/src/ast.rs | 149 ++++++ calcpad-engine/src/context.rs | 77 +++ calcpad-engine/src/error.rs | 30 ++ calcpad-engine/src/ffi.rs | 165 +++++++ calcpad-engine/src/interpreter.rs | 545 +++++++++++++++++++++ calcpad-engine/src/lexer.rs | 430 ++++++++++++++++ calcpad-engine/src/lib.rs | 21 + calcpad-engine/src/number.rs | 698 ++++++++++++++++++++++++++ calcpad-engine/src/parser.rs | 439 +++++++++++++++++ calcpad-engine/src/pipeline.rs | 40 ++ calcpad-engine/src/sheet_context.rs | 683 ++++++++++++++++++++++++++ calcpad-engine/src/span.rs | 20 + calcpad-engine/src/token.rs | 70 +++ calcpad-engine/src/types.rs | 214 ++++++++ calcpad-engine/tests/ffi_tests.rs | 445 +++++++++++++++++ calcpad-wasm/Cargo.toml | 24 + calcpad-wasm/src/lib.rs | 170 +++++++ run-pipeline.sh | 24 +- src/lexer.rs | 733 ---------------------------- src/lib.rs | 5 - src/token.rs | 129 ----- 26 files changed, 5046 insertions(+), 877 deletions(-) create mode 100644 calcpad-engine/Cargo.toml create mode 100644 calcpad-engine/include/calcpad.h create mode 100644 calcpad-engine/src/ast.rs create mode 100644 calcpad-engine/src/context.rs create mode 100644 calcpad-engine/src/error.rs create mode 100644 calcpad-engine/src/ffi.rs create mode 100644 calcpad-engine/src/interpreter.rs create mode 100644 calcpad-engine/src/lexer.rs create mode 100644 calcpad-engine/src/lib.rs create mode 100644 calcpad-engine/src/number.rs create mode 100644 calcpad-engine/src/parser.rs create mode 100644 calcpad-engine/src/pipeline.rs create mode 100644 calcpad-engine/src/sheet_context.rs create mode 100644 calcpad-engine/src/span.rs create mode 100644 calcpad-engine/src/token.rs create mode 100644 calcpad-engine/src/types.rs create mode 100644 calcpad-engine/tests/ffi_tests.rs create mode 100644 calcpad-wasm/Cargo.toml create mode 100644 calcpad-wasm/src/lib.rs delete mode 100644 src/lexer.rs delete mode 100644 src/lib.rs delete mode 100644 src/token.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..af6fce7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,41 @@ +# Build artifacts /target +**/target/ +*.dylib +*.so +*.dll +*.wasm + +# Cargo +Cargo.lock + +# Node +node_modules/ +package-lock.json +dist/ +.next/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +.logs/ + +# Worktrees +.worktrees/ + +# Swift build +.build/ +*.xcuserdata +DerivedData/ + +# WASM +pkg/ diff --git a/Cargo.lock b/Cargo.lock index 438f251..6b3ffda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,662 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "calcpad-engine" version = "0.1.0" +dependencies = [ + "chrono", + "dashu", + "serde", + "serde_json", +] + +[[package]] +name = "calcpad-wasm" +version = "0.1.0" +dependencies = [ + "calcpad-engine", + "chrono", + "js-sys", + "serde", + "serde-wasm-bindgen", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "dashu" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b3e5ac1e23ff1995ef05b912e2b012a8784506987a2651552db2c73fb3d7e0" +dependencies = [ + "dashu-base", + "dashu-float", + "dashu-int", + "dashu-macros", + "dashu-ratio", + "rustversion", +] + +[[package]] +name = "dashu-base" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b80bf6b85aa68c58ffea2ddb040109943049ce3fbdf4385d0380aef08ef289" + +[[package]] +name = "dashu-float" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85078445a8dbd2e1bd21f04a816f352db8d333643f0c9b78ca7c3d1df71063e7" +dependencies = [ + "dashu-base", + "dashu-int", + "num-modular", + "num-order", + "rustversion", + "static_assertions", +] + +[[package]] +name = "dashu-int" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee99d08031ca34a4d044efbbb21dff9b8c54bb9d8c82a189187c0651ffdb9fbf" +dependencies = [ + "cfg-if", + "dashu-base", + "num-modular", + "num-order", + "rustversion", + "static_assertions", +] + +[[package]] +name = "dashu-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93381c3ef6366766f6e9ed9cf09e4ef9dec69499baf04f0c60e70d653cf0ab10" +dependencies = [ + "dashu-base", + "dashu-float", + "dashu-int", + "dashu-ratio", + "paste", + "proc-macro2", + "quote", + "rustversion", +] + +[[package]] +name = "dashu-ratio" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e33b04dd7ce1ccf8a02a69d3419e354f2bbfdf4eb911a0b7465487248764c9" +dependencies = [ + "dashu-base", + "dashu-float", + "dashu-int", + "num-modular", + "num-order", + "rustversion", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6311c867385cc7d5602463b31825d454d0837a3aba7cdb5e56d5201792a3f7fe" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe29135b180b72b04c74aa97b2b4a2ef275161eff9a6c7955ea9eaedc7e1d4e" + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 999bb11..6115e58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ -[package] -name = "calcpad-engine" -version = "0.1.0" -edition = "2021" -description = "Core calculation engine for CalcPad — a modern notepad calculator" - -[dependencies] +[workspace] +members = [ + "calcpad-engine", + "calcpad-wasm", +] +resolver = "2" diff --git a/calcpad-engine/Cargo.toml b/calcpad-engine/Cargo.toml new file mode 100644 index 0000000..d7fd6a2 --- /dev/null +++ b/calcpad-engine/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "calcpad-engine" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib", "rlib"] + +[dependencies] +chrono = "0.4" +dashu = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/calcpad-engine/include/calcpad.h b/calcpad-engine/include/calcpad.h new file mode 100644 index 0000000..ee018a3 --- /dev/null +++ b/calcpad-engine/include/calcpad.h @@ -0,0 +1,90 @@ +/** + * CalcPad Engine — C FFI Header + * + * This header declares the C-compatible interface for the CalcPad calculation + * engine, built in Rust. It is designed for consumption by Swift via a + * bridging header or a module map. + * + * All functions are safe to call from any thread. Panics in Rust are caught + * and converted to error results — they never unwind into the caller. + * + * Memory ownership: + * - Strings returned by calcpad_eval_line / calcpad_eval_sheet are + * heap-allocated by Rust and MUST be freed by calling calcpad_free_result. + * - Passing NULL to calcpad_free_result is a safe no-op. + * + * JSON schema (version "1.0"): + * + * Single-line result (calcpad_eval_line): + * { + * "schema_version": "1.0", + * "result": { + * "value": { "kind": "Number", "value": 42.0 }, + * "metadata": { + * "span": { "start": 0, "end": 4 }, + * "result_type": "Number", + * "display": "42", + * "raw_value": 42.0 + * } + * } + * } + * + * Multi-line result (calcpad_eval_sheet): + * { + * "schema_version": "1.0", + * "results": [ ... ] // array of result objects as above + * } + */ + +#ifndef CALCPAD_H +#define CALCPAD_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Evaluate a single line of CalcPad input. + * + * @param input A null-terminated UTF-8 string containing the expression. + * Passing NULL returns a JSON error result. + * + * @return A heap-allocated, null-terminated JSON string containing the + * versioned result. The caller MUST free this with + * calcpad_free_result(). Returns NULL only on catastrophic + * allocation failure. + */ +char *calcpad_eval_line(const char *input); + +/** + * Evaluate multiple lines of CalcPad input as a sheet. + * + * Variable assignments on earlier lines are visible to later lines + * (e.g., "x = 5" on line 1 makes x available on line 2). + * + * @param lines An array of null-terminated UTF-8 strings. + * NULL entries are treated as empty lines. + * @param count The number of elements in the lines array. + * Must be > 0. + * + * @return A heap-allocated, null-terminated JSON string containing the + * versioned results array. The caller MUST free this with + * calcpad_free_result(). Returns NULL only on catastrophic + * allocation failure. + */ +char *calcpad_eval_sheet(const char *const *lines, int count); + +/** + * Free a result string previously returned by calcpad_eval_line or + * calcpad_eval_sheet. + * + * @param ptr The pointer to free. Passing NULL is safe (no-op). + * After this call the pointer is invalid. + */ +void calcpad_free_result(char *ptr); + +#ifdef __cplusplus +} +#endif + +#endif /* CALCPAD_H */ diff --git a/calcpad-engine/src/ast.rs b/calcpad-engine/src/ast.rs new file mode 100644 index 0000000..f713f50 --- /dev/null +++ b/calcpad-engine/src/ast.rs @@ -0,0 +1,149 @@ +use crate::span::Span; + +/// A spanned AST node — every node carries its source location. +#[derive(Debug, Clone, PartialEq)] +pub struct Spanned { + pub node: T, + pub span: Span, +} + +impl Spanned { + pub fn new(node: T, span: Span) -> Self { + Self { node, span } + } +} + +/// Top-level expression AST. +pub type Expr = Spanned; + +#[derive(Debug, Clone, PartialEq)] +pub enum ExprKind { + /// Numeric literal: `42`, `3.14` + Number(f64), + + /// Number with a unit: `5kg`, `200g` + UnitNumber { value: f64, unit: String }, + + /// Currency value: `$20`, `€15` + CurrencyValue { amount: f64, currency: String }, + + /// Boolean literal + Boolean(bool), + + /// Date literal (year, month, day) + DateLiteral { year: i32, month: u32, day: u32 }, + + /// The keyword `today` + Today, + + /// Duration literal: `3 weeks`, `5 days` + Duration { value: f64, unit: DurationUnit }, + + /// Binary operation: `a + b`, `a * b` + BinaryOp { + op: BinOp, + left: Box, + right: Box, + }, + + /// Unary negation: `-x` + UnaryNeg(Box), + + /// Percentage applied to a value: `100 - 20%`, `$100 + 10%` + PercentOp { + op: PercentOp, + base: Box, + percentage: f64, + }, + + /// Currency conversion: `$20 in EUR` + Conversion { + expr: Box, + target_currency: String, + }, + + /// Date range: `March 12 to July 30` + DateRange { + from: Box, + to: Box, + }, + + /// Comparison: `5 > 3` + Comparison { + op: CmpOp, + left: Box, + right: Box, + }, + + /// Variable reference: `x`, `total` + Identifier(String), + + /// Variable assignment: `x = 5` + Assignment { + name: String, + value: Box, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinOp { + Add, + Sub, + Mul, + Div, + Pow, + Mod, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PercentOp { + Add, + Sub, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CmpOp { + Gt, + Lt, + Gte, + Lte, + Eq, + Neq, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DurationUnit { + Days, + Weeks, + Months, + Years, + Hours, + Minutes, + Seconds, +} + +impl std::fmt::Display for BinOp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BinOp::Add => write!(f, "+"), + BinOp::Sub => write!(f, "-"), + BinOp::Mul => write!(f, "*"), + BinOp::Div => write!(f, "/"), + BinOp::Pow => write!(f, "^"), + BinOp::Mod => write!(f, "%"), + } + } +} + +impl std::fmt::Display for CmpOp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CmpOp::Gt => write!(f, ">"), + CmpOp::Lt => write!(f, "<"), + CmpOp::Gte => write!(f, ">="), + CmpOp::Lte => write!(f, "<="), + CmpOp::Eq => write!(f, "=="), + CmpOp::Neq => write!(f, "!="), + } + } +} diff --git a/calcpad-engine/src/context.rs b/calcpad-engine/src/context.rs new file mode 100644 index 0000000..aa1510c --- /dev/null +++ b/calcpad-engine/src/context.rs @@ -0,0 +1,77 @@ +use crate::types::CalcResult; +use chrono::NaiveDate; +use std::collections::HashMap; + +/// Evaluation context holding exchange rates, current date, variables, and other state +/// needed during interpretation. +pub struct EvalContext { + /// Exchange rates relative to USD. Key is currency code (e.g. "EUR"), + /// value is how many units of that currency per 1 USD. + pub exchange_rates: HashMap, + /// The current date for resolving `today`. + pub today: NaiveDate, + /// Named variables set via assignment expressions (e.g., `x = 5`). + pub variables: HashMap, +} + +impl EvalContext { + pub fn new() -> Self { + Self { + exchange_rates: HashMap::new(), + today: chrono::Local::now().date_naive(), + variables: HashMap::new(), + } + } + + /// Create a context with a fixed date (useful for testing). + pub fn with_date(today: NaiveDate) -> Self { + Self { + exchange_rates: HashMap::new(), + today, + variables: HashMap::new(), + } + } + + /// Set exchange rate: 1 USD = `rate` units of `currency`. + pub fn set_rate(&mut self, currency: &str, rate: f64) { + self.exchange_rates.insert(currency.to_string(), rate); + } + + /// Store a variable result. + pub fn set_variable(&mut self, name: &str, result: CalcResult) { + self.variables.insert(name.to_string(), result); + } + + /// Retrieve a variable result. + pub fn get_variable(&self, name: &str) -> Option<&CalcResult> { + self.variables.get(name) + } + + /// Convert `amount` from `from_currency` to `to_currency`. + /// Returns None if either rate is missing. + pub fn convert_currency( + &self, + amount: f64, + from_currency: &str, + to_currency: &str, + ) -> Option { + let from_rate = if from_currency == "USD" { + 1.0 + } else { + *self.exchange_rates.get(from_currency)? + }; + let to_rate = if to_currency == "USD" { + 1.0 + } else { + *self.exchange_rates.get(to_currency)? + }; + let usd_amount = amount / from_rate; + Some(usd_amount * to_rate) + } +} + +impl Default for EvalContext { + fn default() -> Self { + Self::new() + } +} diff --git a/calcpad-engine/src/error.rs b/calcpad-engine/src/error.rs new file mode 100644 index 0000000..2f399e3 --- /dev/null +++ b/calcpad-engine/src/error.rs @@ -0,0 +1,30 @@ +use crate::span::Span; +use std::fmt; + +/// An error produced during parsing. +#[derive(Debug, Clone, PartialEq)] +pub struct ParseError { + pub message: String, + pub span: Span, +} + +impl ParseError { + pub fn new(message: impl Into, span: Span) -> Self { + Self { + message: message.into(), + span, + } + } +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "parse error at {}..{}: {}", + self.span.start, self.span.end, self.message + ) + } +} + +impl std::error::Error for ParseError {} diff --git a/calcpad-engine/src/ffi.rs b/calcpad-engine/src/ffi.rs new file mode 100644 index 0000000..28a71eb --- /dev/null +++ b/calcpad-engine/src/ffi.rs @@ -0,0 +1,165 @@ +use crate::context::EvalContext; +use crate::pipeline; +use crate::span::Span; +use crate::types::CalcResult; +use serde::{Deserialize, Serialize}; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::panic; +use std::ptr; + +/// Versioned FFI response wrapper for JSON serialization. +/// The schema_version field allows Swift to handle backward-compatible changes. +#[derive(Serialize, Deserialize)] +pub struct FfiResponse { + /// Schema version for forward compatibility. + pub schema_version: String, + /// The calculation result. + pub result: CalcResult, +} + +/// Versioned FFI response for sheet (multi-line) evaluation. +#[derive(Serialize, Deserialize)] +pub struct FfiSheetResponse { + /// Schema version for forward compatibility. + pub schema_version: String, + /// Array of calculation results, one per line. + pub results: Vec, +} + +const SCHEMA_VERSION: &str = "1.0"; + +fn make_error_json(message: &str) -> *mut c_char { + let response = FfiResponse { + schema_version: SCHEMA_VERSION.to_string(), + result: CalcResult::error(message, Span::new(0, 0)), + }; + match serde_json::to_string(&response) { + Ok(json) => match CString::new(json) { + Ok(cs) => cs.into_raw(), + Err(_) => ptr::null_mut(), + }, + Err(_) => ptr::null_mut(), + } +} + +/// Evaluate a single line of CalcPad input. +/// +/// # Safety +/// - `input` must be a valid, null-terminated C string. +/// - The returned pointer is heap-allocated and must be freed by calling +/// `calcpad_free_result`. +/// +/// Returns a JSON-serialized string with schema version, result type, +/// display value, raw value, and any error information. +#[no_mangle] +pub unsafe extern "C" fn calcpad_eval_line(input: *const c_char) -> *mut c_char { + // Catch any panics to prevent unwinding into Swift + let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + if input.is_null() { + return make_error_json("null input pointer"); + } + + let c_str = match CStr::from_ptr(input).to_str() { + Ok(s) => s, + Err(_) => return make_error_json("invalid UTF-8 input"), + }; + + let mut ctx = EvalContext::new(); + let calc_result = pipeline::eval_line(c_str, &mut ctx); + + let response = FfiResponse { + schema_version: SCHEMA_VERSION.to_string(), + result: calc_result, + }; + + match serde_json::to_string(&response) { + Ok(json) => match CString::new(json) { + Ok(cs) => cs.into_raw(), + Err(_) => make_error_json("result contains null byte"), + }, + Err(e) => make_error_json(&format!("serialization error: {}", e)), + } + })); + + match result { + Ok(ptr) => ptr, + Err(_) => make_error_json("internal panic caught"), + } +} + +/// Evaluate multiple lines of CalcPad input as a sheet. +/// +/// Variable assignments on earlier lines are visible to later lines. +/// +/// # Safety +/// - `lines` must be an array of `count` valid, null-terminated C strings. +/// - `count` must accurately reflect the number of elements in the array. +/// - The returned pointer is heap-allocated and must be freed by calling +/// `calcpad_free_result`. +/// +/// Returns a JSON-serialized string containing an array of results. +#[no_mangle] +pub unsafe extern "C" fn calcpad_eval_sheet( + lines: *const *const c_char, + count: i32, +) -> *mut c_char { + let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + if lines.is_null() || count <= 0 { + return make_error_json("null or empty input"); + } + + let mut rust_lines: Vec = Vec::with_capacity(count as usize); + + for i in 0..count as usize { + let line_ptr = *lines.add(i); + if line_ptr.is_null() { + rust_lines.push(String::new()); + continue; + } + match CStr::from_ptr(line_ptr).to_str() { + Ok(s) => rust_lines.push(s.to_string()), + Err(_) => rust_lines.push(String::new()), + } + } + + let line_refs: Vec<&str> = rust_lines.iter().map(|s| s.as_str()).collect(); + let mut ctx = EvalContext::new(); + let results = pipeline::eval_sheet(&line_refs, &mut ctx); + + let response = FfiSheetResponse { + schema_version: SCHEMA_VERSION.to_string(), + results, + }; + + match serde_json::to_string(&response) { + Ok(json) => match CString::new(json) { + Ok(cs) => cs.into_raw(), + Err(_) => make_error_json("result contains null byte"), + }, + Err(e) => make_error_json(&format!("serialization error: {}", e)), + } + })); + + match result { + Ok(ptr) => ptr, + Err(_) => make_error_json("internal panic caught"), + } +} + +/// Free a result string previously returned by `calcpad_eval_line` or +/// `calcpad_eval_sheet`. +/// +/// # Safety +/// - `ptr` must be a pointer previously returned by `calcpad_eval_line` or +/// `calcpad_eval_sheet`, or null. +/// - After calling this function, the pointer is invalid and must not be used. +/// - Passing null is safe and results in a no-op. +#[no_mangle] +pub unsafe extern "C" fn calcpad_free_result(ptr: *mut c_char) { + if ptr.is_null() { + return; + } + // Reconstruct the CString so Rust's allocator can free the memory + drop(CString::from_raw(ptr)); +} diff --git a/calcpad-engine/src/interpreter.rs b/calcpad-engine/src/interpreter.rs new file mode 100644 index 0000000..2cf4bd4 --- /dev/null +++ b/calcpad-engine/src/interpreter.rs @@ -0,0 +1,545 @@ +use crate::ast::*; +use crate::context::EvalContext; +use crate::span::Span; +use crate::types::*; +use chrono::{Duration, NaiveDate}; + +/// Internal intermediate value used during evaluation. +#[derive(Debug, Clone)] +enum Value { + Number(f64), + UnitValue { value: f64, unit: String }, + CurrencyValue { amount: f64, currency: String }, + DateTime(NaiveDate), + TimeDelta { days: i64, description: String }, + Boolean(bool), +} + +/// Evaluate an AST expression within the given context. +pub fn evaluate(expr: &Expr, ctx: &mut EvalContext) -> CalcResult { + let span = expr.span; + match eval_inner(expr, ctx) { + Ok(val) => value_to_result(val, span), + Err(msg) => CalcResult::error(&msg, span), + } +} + +fn eval_inner(expr: &Expr, ctx: &mut EvalContext) -> Result { + match &expr.node { + ExprKind::Number(n) => Ok(Value::Number(*n)), + + ExprKind::Boolean(b) => Ok(Value::Boolean(*b)), + + ExprKind::UnitNumber { value, unit } => Ok(Value::UnitValue { + value: *value, + unit: unit.clone(), + }), + + ExprKind::CurrencyValue { amount, currency } => Ok(Value::CurrencyValue { + amount: *amount, + currency: currency.clone(), + }), + + ExprKind::DateLiteral { year, month, day } => { + let date = NaiveDate::from_ymd_opt(*year, *month, *day) + .ok_or_else(|| format!("Invalid date: {}-{}-{}", year, month, day))?; + Ok(Value::DateTime(date)) + } + + ExprKind::Today => Ok(Value::DateTime(ctx.today)), + + ExprKind::Duration { value, unit } => { + let days = duration_to_days(*value, *unit); + let desc = format_duration(*value, *unit); + Ok(Value::TimeDelta { + days, + description: desc, + }) + } + + ExprKind::BinaryOp { op, left, right } => { + let lval = eval_inner(left, ctx)?; + let rval = eval_inner(right, ctx)?; + eval_binary(*op, lval, rval, ctx) + } + + ExprKind::UnaryNeg(inner) => { + let val = eval_inner(inner, ctx)?; + match val { + Value::Number(n) => Ok(Value::Number(-n)), + Value::UnitValue { value, unit } => Ok(Value::UnitValue { + value: -value, + unit, + }), + Value::CurrencyValue { amount, currency } => Ok(Value::CurrencyValue { + amount: -amount, + currency, + }), + _ => Err("cannot negate this value".to_string()), + } + } + + ExprKind::PercentOp { + op, + base, + percentage, + } => { + let base_val = eval_inner(base, ctx)?; + eval_percent(*op, base_val, *percentage) + } + + ExprKind::Conversion { + expr, + target_currency, + } => { + let val = eval_inner(expr, ctx)?; + eval_conversion(val, target_currency, ctx) + } + + ExprKind::DateRange { from, to } => { + let from_val = eval_inner(from, ctx)?; + let to_val = eval_inner(to, ctx)?; + match (from_val, to_val) { + (Value::DateTime(d1), Value::DateTime(d2)) => { + let days = (d2 - d1).num_days(); + Ok(Value::TimeDelta { + days, + description: format!("{} days", days), + }) + } + _ => Err("date range requires two dates".to_string()), + } + } + + ExprKind::Comparison { op, left, right } => { + let lval = eval_inner(left, ctx)?; + let rval = eval_inner(right, ctx)?; + eval_comparison(*op, lval, rval) + } + + ExprKind::Identifier(name) => { + if let Some(result) = ctx.get_variable(name) { + result_to_value(result) + } else { + Err(format!("undefined variable: {}", name)) + } + } + + ExprKind::Assignment { name, value } => { + let val = eval_inner(value, ctx)?; + let result = value_to_result(val.clone(), value.span); + ctx.set_variable(name, result); + Ok(val) + } + } +} + +fn result_to_value(result: &CalcResult) -> Result { + match &result.value { + CalcValue::Number { value } => Ok(Value::Number(*value)), + CalcValue::UnitValue { value, unit } => Ok(Value::UnitValue { + value: *value, + unit: unit.clone(), + }), + CalcValue::CurrencyValue { amount, currency } => Ok(Value::CurrencyValue { + amount: *amount, + currency: currency.clone(), + }), + CalcValue::Boolean { value } => Ok(Value::Boolean(*value)), + CalcValue::DateTime { date } => { + let d = NaiveDate::parse_from_str(date, "%Y-%m-%d") + .map_err(|e| format!("invalid date: {}", e))?; + Ok(Value::DateTime(d)) + } + CalcValue::TimeDelta { days, description } => Ok(Value::TimeDelta { + days: *days, + description: description.clone(), + }), + CalcValue::Error { message, .. } => Err(message.clone()), + } +} + +fn eval_binary( + op: BinOp, + lval: Value, + rval: Value, + ctx: &EvalContext, +) -> Result { + match (lval, rval) { + // Number op Number + (Value::Number(a), Value::Number(b)) => { + let result = match op { + BinOp::Add => a + b, + BinOp::Sub => a - b, + BinOp::Mul => a * b, + BinOp::Div => { + if b == 0.0 { + return Err("division by zero".to_string()); + } + a / b + } + BinOp::Pow => a.powf(b), + BinOp::Mod => { + if b == 0.0 { + return Err("modulo by zero".to_string()); + } + a % b + } + }; + Ok(Value::Number(result)) + } + + // UnitValue op Number (scalar operations) + (Value::UnitValue { value, unit }, Value::Number(b)) => { + let result = match op { + BinOp::Mul => b * value, + BinOp::Div => { + if b == 0.0 { + return Err("division by zero".to_string()); + } + value / b + } + BinOp::Add => value + b, + BinOp::Sub => value - b, + _ => return Err(format!("unsupported operation: unit {} number", op)), + }; + Ok(Value::UnitValue { + value: result, + unit, + }) + } + + // Number op UnitValue + (Value::Number(a), Value::UnitValue { value, unit }) => { + let result = match op { + BinOp::Mul => a * value, + _ => return Err(format!("unsupported operation: number {} unit", op)), + }; + Ok(Value::UnitValue { + value: result, + unit, + }) + } + + // UnitValue op UnitValue (same unit) + ( + Value::UnitValue { + value: a, + unit: u1, + }, + Value::UnitValue { + value: b, + unit: u2, + }, + ) => { + if u1 != u2 { + // Try unit conversion + if let Some(converted) = convert_units(b, &u2, &u1) { + let result = match op { + BinOp::Add => a + converted, + BinOp::Sub => a - converted, + _ => { + return Err(format!( + "unsupported operation between {} and {}", + u1, u2 + )) + } + }; + return Ok(Value::UnitValue { + value: result, + unit: u1, + }); + } + return Err(format!("incompatible units: {} and {}", u1, u2)); + } + let result = match op { + BinOp::Add => a + b, + BinOp::Sub => a - b, + _ => return Err(format!("unsupported operation between units: {}", op)), + }; + Ok(Value::UnitValue { + value: result, + unit: u1, + }) + } + + // Currency op Currency + ( + Value::CurrencyValue { + amount: a, + currency: c1, + }, + Value::CurrencyValue { + amount: b, + currency: c2, + }, + ) => { + if c1 != c2 { + if let Some(converted) = ctx.convert_currency(b, &c2, &c1) { + let result = match op { + BinOp::Add => a + converted, + BinOp::Sub => a - converted, + _ => return Err("unsupported currency operation".to_string()), + }; + return Ok(Value::CurrencyValue { + amount: result, + currency: c1, + }); + } + return Err(format!("cannot convert between {} and {}", c1, c2)); + } + let result = match op { + BinOp::Add => a + b, + BinOp::Sub => a - b, + _ => return Err("unsupported currency operation".to_string()), + }; + Ok(Value::CurrencyValue { + amount: result, + currency: c1, + }) + } + + // Currency op Number + (Value::CurrencyValue { amount, currency }, Value::Number(b)) => { + let result = match op { + BinOp::Mul => amount * b, + BinOp::Div => { + if b == 0.0 { + return Err("division by zero".to_string()); + } + amount / b + } + BinOp::Add => amount + b, + BinOp::Sub => amount - b, + _ => return Err("unsupported currency operation".to_string()), + }; + Ok(Value::CurrencyValue { + amount: result, + currency, + }) + } + + // Number op Currency + (Value::Number(a), Value::CurrencyValue { amount, currency }) => { + let result = match op { + BinOp::Mul => a * amount, + _ => return Err("unsupported currency operation".to_string()), + }; + Ok(Value::CurrencyValue { + amount: result, + currency, + }) + } + + // DateTime +/- Duration + (Value::DateTime(date), Value::TimeDelta { days, .. }) => match op { + BinOp::Add => { + let new_date = date + Duration::days(days); + Ok(Value::DateTime(new_date)) + } + BinOp::Sub => { + let new_date = date - Duration::days(days); + Ok(Value::DateTime(new_date)) + } + _ => Err("unsupported date operation".to_string()), + }, + + // DateTime - DateTime + (Value::DateTime(d1), Value::DateTime(d2)) => match op { + BinOp::Sub => { + let days = (d1 - d2).num_days(); + Ok(Value::TimeDelta { + days, + description: format!("{} days", days), + }) + } + _ => Err("unsupported date operation".to_string()), + }, + + _ => Err(format!("unsupported binary operation: {}", op)), + } +} + +fn eval_percent(op: PercentOp, base: Value, percentage: f64) -> Result { + let factor = percentage / 100.0; + match base { + Value::Number(n) => { + let result = match op { + PercentOp::Add => n + n * factor, + PercentOp::Sub => n - n * factor, + }; + Ok(Value::Number(result)) + } + Value::CurrencyValue { amount, currency } => { + let result = match op { + PercentOp::Add => amount + amount * factor, + PercentOp::Sub => amount - amount * factor, + }; + Ok(Value::CurrencyValue { + amount: result, + currency, + }) + } + Value::UnitValue { value, unit } => { + let result = match op { + PercentOp::Add => value + value * factor, + PercentOp::Sub => value - value * factor, + }; + Ok(Value::UnitValue { + value: result, + unit, + }) + } + _ => Err("percentage operation requires numeric value".to_string()), + } +} + +fn eval_conversion( + val: Value, + target: &str, + ctx: &EvalContext, +) -> Result { + match val { + Value::CurrencyValue { amount, currency } => { + if let Some(converted) = ctx.convert_currency(amount, ¤cy, target) { + Ok(Value::CurrencyValue { + amount: converted, + currency: target.to_string(), + }) + } else { + Err(format!( + "no exchange rate available for {} to {}", + currency, target + )) + } + } + Value::UnitValue { value, unit } => { + if let Some(converted) = convert_units(value, &unit, target) { + Ok(Value::UnitValue { + value: converted, + unit: target.to_string(), + }) + } else { + Err(format!("cannot convert {} to {}", unit, target)) + } + } + _ => Err("conversion requires a currency or unit value".to_string()), + } +} + +fn eval_comparison(op: CmpOp, lval: Value, rval: Value) -> Result { + let (a, b) = match (lval, rval) { + (Value::Number(a), Value::Number(b)) => (a, b), + _ => return Err("comparison requires numeric values".to_string()), + }; + let result = match op { + CmpOp::Gt => a > b, + CmpOp::Lt => a < b, + CmpOp::Gte => a >= b, + CmpOp::Lte => a <= b, + CmpOp::Eq => (a - b).abs() < f64::EPSILON, + CmpOp::Neq => (a - b).abs() >= f64::EPSILON, + }; + Ok(Value::Boolean(result)) +} + +fn value_to_result(val: Value, span: Span) -> CalcResult { + match val { + Value::Number(n) => CalcResult::number(n, span), + Value::UnitValue { value, unit } => CalcResult::unit_value(value, &unit, span), + Value::CurrencyValue { amount, currency } => { + CalcResult::currency_value(amount, ¤cy, span) + } + Value::DateTime(date) => CalcResult::datetime(date, span), + Value::TimeDelta { days, description } => CalcResult::time_delta(days, &description, span), + Value::Boolean(b) => CalcResult::boolean(b, span), + } +} + +fn duration_to_days(value: f64, unit: DurationUnit) -> i64 { + let days = match unit { + DurationUnit::Days => value, + DurationUnit::Weeks => value * 7.0, + DurationUnit::Months => value * 30.0, + DurationUnit::Years => value * 365.0, + DurationUnit::Hours => value / 24.0, + DurationUnit::Minutes => value / (24.0 * 60.0), + DurationUnit::Seconds => value / (24.0 * 60.0 * 60.0), + }; + days as i64 +} + +fn format_duration(value: f64, unit: DurationUnit) -> String { + let unit_str = match unit { + DurationUnit::Days => "days", + DurationUnit::Weeks => "weeks", + DurationUnit::Months => "months", + DurationUnit::Years => "years", + DurationUnit::Hours => "hours", + DurationUnit::Minutes => "minutes", + DurationUnit::Seconds => "seconds", + }; + if value == 1.0 { + format!("1 {}", unit_str.trim_end_matches('s')) + } else { + format!("{} {}", value, unit_str) + } +} + +fn convert_units(value: f64, from: &str, to: &str) -> Option { + // Normalize to base unit, then convert to target + let (base_value, base_unit) = to_base_unit(value, from)?; + from_base_unit(base_value, &base_unit, to) +} + +fn to_base_unit(value: f64, unit: &str) -> Option<(f64, String)> { + match unit { + // Mass → grams + "kg" => Some((value * 1000.0, "g".to_string())), + "g" => Some((value, "g".to_string())), + "mg" => Some((value / 1000.0, "g".to_string())), + "lb" => Some((value * 453.592, "g".to_string())), + "oz" => Some((value * 28.3495, "g".to_string())), + // Length → meters + "m" => Some((value, "m".to_string())), + "cm" => Some((value / 100.0, "m".to_string())), + "mm" => Some((value / 1000.0, "m".to_string())), + "km" => Some((value * 1000.0, "m".to_string())), + "ft" => Some((value * 0.3048, "m".to_string())), + "in" => Some((value * 0.0254, "m".to_string())), + // Volume → liters + "l" => Some((value, "l".to_string())), + "ml" => Some((value / 1000.0, "l".to_string())), + // Time → seconds + "s" => Some((value, "s".to_string())), + "min" => Some((value * 60.0, "s".to_string())), + "h" => Some((value * 3600.0, "s".to_string())), + _ => None, + } +} + +fn from_base_unit(base_value: f64, base_unit: &str, target: &str) -> Option { + match (base_unit, target) { + // Mass (base: grams) + ("g", "kg") => Some(base_value / 1000.0), + ("g", "g") => Some(base_value), + ("g", "mg") => Some(base_value * 1000.0), + ("g", "lb") => Some(base_value / 453.592), + ("g", "oz") => Some(base_value / 28.3495), + // Length (base: meters) + ("m", "m") => Some(base_value), + ("m", "cm") => Some(base_value * 100.0), + ("m", "mm") => Some(base_value * 1000.0), + ("m", "km") => Some(base_value / 1000.0), + ("m", "ft") => Some(base_value / 0.3048), + ("m", "in") => Some(base_value / 0.0254), + // Volume (base: liters) + ("l", "l") => Some(base_value), + ("l", "ml") => Some(base_value * 1000.0), + // Time (base: seconds) + ("s", "s") => Some(base_value), + ("s", "min") => Some(base_value / 60.0), + ("s", "h") => Some(base_value / 3600.0), + _ => None, + } +} diff --git a/calcpad-engine/src/lexer.rs b/calcpad-engine/src/lexer.rs new file mode 100644 index 0000000..ff918ea --- /dev/null +++ b/calcpad-engine/src/lexer.rs @@ -0,0 +1,430 @@ +use crate::span::Span; +use crate::token::{Operator, Token, TokenKind}; + +/// A line-oriented lexer for CalcPad expressions. +pub struct Lexer<'a> { + input: &'a str, + bytes: &'a [u8], + pos: usize, + pending: Option, +} + +impl<'a> Lexer<'a> { + pub fn new(input: &'a str) -> Self { + Self { + input, + bytes: input.as_bytes(), + pos: 0, + pending: None, + } + } + + /// Tokenize the entire input line into a token stream, terminated with Eof. + pub fn tokenize(&mut self) -> Vec { + if self.input.trim().is_empty() { + return vec![Token::new(TokenKind::Eof, Span::new(0, 0))]; + } + + // Full-line comment check + let trimmed = self.input.trim_start(); + let trimmed_start = self.input.len() - trimmed.len(); + if trimmed.starts_with("//") { + let comment_text = self.input[trimmed_start + 2..].to_string(); + return vec![ + Token::new( + TokenKind::Comment(comment_text), + Span::new(trimmed_start, self.input.len()), + ), + Token::new(TokenKind::Eof, Span::new(self.input.len(), self.input.len())), + ]; + } + + let mut tokens = Vec::new(); + loop { + if let Some(tok) = self.pending.take() { + tokens.push(tok); + continue; + } + if self.pos >= self.bytes.len() { + break; + } + self.skip_whitespace(); + if self.pos >= self.bytes.len() { + break; + } + if let Some(tok) = self.scan_token() { + tokens.push(tok); + } + } + + // If no calculable tokens, return as text + if tokens.is_empty() && !self.input.trim().is_empty() { + return vec![ + Token::new( + TokenKind::Text(self.input.to_string()), + Span::new(0, self.input.len()), + ), + Token::new(TokenKind::Eof, Span::new(self.input.len(), self.input.len())), + ]; + } + + let has_assign = tokens.iter().any(|t| matches!(t.kind, TokenKind::Assign)); + let has_calculable = tokens.iter().any(|t| { + matches!( + t.kind, + TokenKind::Number(_) + | TokenKind::Op(_) + | TokenKind::Assign + | TokenKind::Percent(_) + | TokenKind::CurrencySymbol(_) + | TokenKind::Unit(_) + | TokenKind::LParen + | TokenKind::RParen + ) + }); + // A single identifier token (potential variable reference) is also calculable + let is_single_identifier = !has_calculable + && tokens.len() == 1 + && matches!(tokens[0].kind, TokenKind::Identifier(_)); + // An identifier with assignment is also calculable + let has_identifier_with_assign = has_assign + && tokens.iter().any(|t| matches!(t.kind, TokenKind::Identifier(_))); + if !has_calculable && !is_single_identifier && !has_identifier_with_assign { + return vec![ + Token::new( + TokenKind::Text(self.input.to_string()), + Span::new(0, self.input.len()), + ), + Token::new(TokenKind::Eof, Span::new(self.input.len(), self.input.len())), + ]; + } + + let end = self.input.len(); + tokens.push(Token::new(TokenKind::Eof, Span::new(end, end))); + tokens + } + + fn skip_whitespace(&mut self) { + while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() { + self.pos += 1; + } + } + + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn peek_ahead(&self, n: usize) -> Option { + self.bytes.get(self.pos + n).copied() + } + + fn advance(&mut self) -> Option { + if self.pos < self.bytes.len() { + let b = self.bytes[self.pos]; + self.pos += 1; + Some(b) + } else { + None + } + } + + fn matches_word(&self, word: &str) -> bool { + let remaining = &self.input[self.pos..]; + if remaining.len() < word.len() { + return false; + } + if !remaining[..word.len()].eq_ignore_ascii_case(word) { + return false; + } + if remaining.len() == word.len() { + return true; + } + let next = remaining.as_bytes()[word.len()]; + !next.is_ascii_alphanumeric() && next != b'_' + } + + fn scan_token(&mut self) -> Option { + let b = self.peek()?; + + // Comment: // ... + if b == b'/' && self.peek_ahead(1) == Some(b'/') { + return Some(self.scan_comment()); + } + + // Currency symbols + if let Some(tok) = self.try_scan_currency() { + return Some(tok); + } + + // Numbers + if b.is_ascii_digit() + || (b == b'.' && self.peek_ahead(1).is_some_and(|c| c.is_ascii_digit())) + { + return Some(self.scan_number()); + } + + // Two-character comparison operators + if b == b'>' && self.peek_ahead(1) == Some(b'=') { + let start = self.pos; + self.pos += 2; + return Some(Token::new(TokenKind::GreaterEq, Span::new(start, self.pos))); + } + if b == b'<' && self.peek_ahead(1) == Some(b'=') { + let start = self.pos; + self.pos += 2; + return Some(Token::new(TokenKind::LessEq, Span::new(start, self.pos))); + } + if b == b'=' && self.peek_ahead(1) == Some(b'=') { + let start = self.pos; + self.pos += 2; + return Some(Token::new(TokenKind::Equal, Span::new(start, self.pos))); + } + if b == b'!' && self.peek_ahead(1) == Some(b'=') { + let start = self.pos; + self.pos += 2; + return Some(Token::new(TokenKind::NotEqual, Span::new(start, self.pos))); + } + + // Single-character operators and punctuation + match b { + b'+' => return Some(self.single_char_token(TokenKind::Op(Operator::Add))), + b'-' => return Some(self.single_char_token(TokenKind::Op(Operator::Subtract))), + b'*' => return Some(self.single_char_token(TokenKind::Op(Operator::Multiply))), + b'/' => return Some(self.single_char_token(TokenKind::Op(Operator::Divide))), + b'^' => return Some(self.single_char_token(TokenKind::Op(Operator::Power))), + b'(' => return Some(self.single_char_token(TokenKind::LParen)), + b')' => return Some(self.single_char_token(TokenKind::RParen)), + b'>' => return Some(self.single_char_token(TokenKind::Greater)), + b'<' => return Some(self.single_char_token(TokenKind::Less)), + b'=' => return Some(self.single_char_token(TokenKind::Assign)), + b'%' => return Some(self.single_char_token(TokenKind::Op(Operator::Modulo))), + _ => {} + } + + // Alphabetic — could be keyword, unit, or identifier + if b.is_ascii_alphabetic() || b == b'_' { + return Some(self.scan_word()); + } + + // Unknown character — skip + self.advance(); + None + } + + fn single_char_token(&mut self, kind: TokenKind) -> Token { + let start = self.pos; + self.advance(); + Token::new(kind, Span::new(start, self.pos)) + } + + fn scan_comment(&mut self) -> Token { + let start = self.pos; + self.pos += 2; + let text = self.input[self.pos..].to_string(); + self.pos = self.bytes.len(); + Token::new(TokenKind::Comment(text), Span::new(start, self.pos)) + } + + fn try_scan_currency(&mut self) -> Option { + let start = self.pos; + let remaining = &self.input[self.pos..]; + + if remaining.starts_with("R$") { + self.pos += 2; + return Some(Token::new( + TokenKind::CurrencySymbol("R$".to_string()), + Span::new(start, self.pos), + )); + } + if remaining.starts_with('$') { + self.pos += 1; + return Some(Token::new( + TokenKind::CurrencySymbol("$".to_string()), + Span::new(start, self.pos), + )); + } + for sym in &["€", "£", "¥"] { + if remaining.starts_with(sym) { + self.pos += sym.len(); + return Some(Token::new( + TokenKind::CurrencySymbol(sym.to_string()), + Span::new(start, self.pos), + )); + } + } + None + } + + fn scan_number(&mut self) -> Token { + let start = self.pos; + + self.consume_digits(); + + // Decimal point + if self.peek() == Some(b'.') + && self.peek_ahead(1).is_some_and(|c| c.is_ascii_digit()) + { + self.advance(); + self.consume_digits(); + } + + // Scientific notation + if let Some(e) = self.peek() { + if e == b'e' || e == b'E' { + let next = self.peek_ahead(1); + let has_digits = match next { + Some(b'+') | Some(b'-') => { + self.peek_ahead(2).is_some_and(|c| c.is_ascii_digit()) + } + Some(c) => c.is_ascii_digit(), + None => false, + }; + if has_digits { + self.advance(); + if let Some(b'+') | Some(b'-') = self.peek() { + self.advance(); + } + self.consume_digits(); + } + } + } + + let number_end = self.pos; + let raw_number: f64 = self.input[start..number_end].parse().unwrap_or(0.0); + + // Check for percent suffix + if self.peek() == Some(b'%') { + self.advance(); + return Token::new(TokenKind::Percent(raw_number), Span::new(start, self.pos)); + } + + // SI scale suffixes + if let Some(suffix) = self.peek() { + let scale = match suffix { + b'k' if !self.is_unit_suffix_start() => Some(1_000.0), + b'M' if !self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) => { + Some(1_000_000.0) + } + b'B' if !self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) => { + Some(1_000_000_000.0) + } + b'T' if !self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) => { + Some(1_000_000_000_000.0) + } + _ => None, + }; + if let Some(multiplier) = scale { + self.advance(); + return Token::new( + TokenKind::Number(raw_number * multiplier), + Span::new(start, self.pos), + ); + } + } + + // Unit suffix directly after number + if let Some(b) = self.peek() { + if b.is_ascii_alphabetic() { + let unit_start = self.pos; + self.consume_alpha(); + let unit_str = self.input[unit_start..self.pos].to_string(); + self.pending = Some(Token::new( + TokenKind::Unit(unit_str), + Span::new(unit_start, self.pos), + )); + return Token::new( + TokenKind::Number(raw_number), + Span::new(start, number_end), + ); + } + } + + Token::new(TokenKind::Number(raw_number), Span::new(start, number_end)) + } + + fn is_unit_suffix_start(&self) -> bool { + self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) + } + + fn consume_digits(&mut self) { + while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() { + self.pos += 1; + } + } + + fn consume_alpha(&mut self) { + while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_alphabetic() { + self.pos += 1; + } + } + + fn scan_word(&mut self) -> Token { + // "divided by" two-word operator + if self.matches_word("divided") { + let start = self.pos; + self.pos += "divided".len(); + self.skip_whitespace(); + if self.matches_word("by") { + self.pos += "by".len(); + return Token::new( + TokenKind::Op(Operator::Divide), + Span::new(start, self.pos), + ); + } + self.pos = start + "divided".len(); + return Token::new( + TokenKind::Identifier("divided".to_string()), + Span::new(start, self.pos), + ); + } + + // Natural language operators + let nl_ops: &[(&str, Operator)] = &[ + ("plus", Operator::Add), + ("minus", Operator::Subtract), + ("times", Operator::Multiply), + ]; + for &(word, ref op) in nl_ops { + if self.matches_word(word) { + let start = self.pos; + self.pos += word.len(); + return Token::new(TokenKind::Op(*op), Span::new(start, self.pos)); + } + } + + // The keyword `in` (for conversions) + if self.matches_word("in") { + let start = self.pos; + self.pos += 2; + return Token::new(TokenKind::In, Span::new(start, self.pos)); + } + + // Other keywords + let keywords = ["to", "as", "of", "discount", "off", "euro", "usd", "gbp"]; + for kw in &keywords { + if self.matches_word(kw) { + let start = self.pos; + self.pos += kw.len(); + return Token::new( + TokenKind::Keyword(kw.to_string()), + Span::new(start, self.pos), + ); + } + } + + // Generic identifier + let start = self.pos; + while self.pos < self.bytes.len() + && (self.bytes[self.pos].is_ascii_alphanumeric() || self.bytes[self.pos] == b'_') + { + self.pos += 1; + } + let word = self.input[start..self.pos].to_string(); + Token::new(TokenKind::Identifier(word), Span::new(start, self.pos)) + } +} + +/// Convenience function to tokenize an input line. +pub fn tokenize(input: &str) -> Vec { + Lexer::new(input).tokenize() +} diff --git a/calcpad-engine/src/lib.rs b/calcpad-engine/src/lib.rs new file mode 100644 index 0000000..445e75e --- /dev/null +++ b/calcpad-engine/src/lib.rs @@ -0,0 +1,21 @@ +pub mod ast; +pub mod context; +pub mod error; +pub mod ffi; +pub mod interpreter; +pub mod lexer; +pub mod number; +pub mod parser; +pub mod pipeline; +pub mod sheet_context; +pub mod span; +pub mod token; +pub mod types; + +pub use context::EvalContext; +pub use ffi::{FfiResponse, FfiSheetResponse}; +pub use interpreter::evaluate; +pub use pipeline::{eval_line, eval_sheet}; +pub use sheet_context::SheetContext; +pub use span::Span; +pub use types::{CalcResult, CalcValue, ResultMetadata, ResultType}; diff --git a/calcpad-engine/src/number.rs b/calcpad-engine/src/number.rs new file mode 100644 index 0000000..1568a2d --- /dev/null +++ b/calcpad-engine/src/number.rs @@ -0,0 +1,698 @@ +//! Arbitrary-precision number type for CalcText. +//! +//! Uses [`dashu`](https://docs.rs/dashu) for exact integer and rational arithmetic, +//! which is pure Rust and WASM-compatible. Numbers are stored as either exact integers +//! (`IBig`) or exact rationals (`RBig`), avoiding floating-point representation errors. +//! +//! # Examples +//! +//! ``` +//! use calcpad_engine::number::Number; +//! +//! // 0.1 + 0.2 == 0.3 exactly (no floating-point drift) +//! let a = Number::parse("0.1").unwrap(); +//! let b = Number::parse("0.2").unwrap(); +//! let sum = a + b; +//! assert_eq!(sum.to_string(), "0.3"); +//! ``` + +use dashu::integer::IBig; +use dashu::rational::RBig; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Default number of significant digits for formatting non-terminating decimals. +pub const DEFAULT_PRECISION: usize = 30; + +/// Arbitrary-precision number. +/// +/// Uses `Integer` for exact integer values and `Rational` for exact +/// fractional values. Decimal inputs like `"0.1"` are stored as rationals +/// (1/10) to avoid floating-point representation errors. +#[derive(Clone, Debug)] +pub enum Number { + Integer(IBig), + Rational(RBig), +} + +// --------------------------------------------------------------------------- +// Construction & parsing +// --------------------------------------------------------------------------- + +impl Number { + /// Parse a decimal string into a `Number`, preserving exact representation. + /// + /// - `"42"` becomes `Integer(42)` + /// - `"0.1"` becomes `Rational(1/10)` + /// - `"-3.14"` becomes `Rational(-314/100)` (auto-reduced) + pub fn parse(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + + // Handle optional leading sign + let (is_negative, s) = if let Some(rest) = s.strip_prefix('-') { + (true, rest) + } else if let Some(rest) = s.strip_prefix('+') { + (false, rest) + } else { + (false, s) + }; + + let result = if s.contains('.') { + // Parse as exact rational: "3.14" -> 314/100, auto-reduced by RBig + let parts: Vec<&str> = s.splitn(2, '.').collect(); + let integer_part = parts[0]; + let fractional_part = parts[1]; + let decimal_places = fractional_part.len() as u32; + + let numerator_str = format!("{}{}", integer_part, fractional_part); + let numerator: IBig = numerator_str.parse().ok()?; + let denominator: IBig = IBig::from(10).pow(decimal_places as usize); + + let rational = RBig::from_parts(numerator.into(), denominator.try_into().ok()?); + Number::from_rational(rational) + } else { + let i: IBig = s.parse().ok()?; + Number::Integer(i) + }; + + if is_negative { + Some(-result) + } else { + Some(result) + } + } + + /// Construct from a rational, normalising to `Integer` if the denominator is 1. + pub fn from_rational(r: RBig) -> Number { + let (num, den) = r.clone().into_parts(); + if *den.as_ibig() == IBig::from(1) { + Number::Integer(num) + } else { + Number::Rational(r) + } + } + + /// Promote to `RBig` regardless of variant. + pub fn to_rational(&self) -> RBig { + match self { + Number::Integer(i) => RBig::from(i.clone()), + Number::Rational(r) => r.clone(), + } + } + + /// Convert to `f64` (lossy — for serialisation / FFI only). + pub fn to_f64(&self) -> f64 { + match self { + Number::Integer(i) => { + // Try i64 first for exactness on small ints + if let Ok(n) = i64::try_from(i.clone()) { + n as f64 + } else { + // Fall back to string parsing for huge numbers + i.to_string().parse::().unwrap_or(f64::INFINITY) + } + } + Number::Rational(r) => { + let (num, den) = r.clone().into_parts(); + let n = if let Ok(n) = i64::try_from(num.clone()) { + n as f64 + } else { + num.to_string().parse::().unwrap_or(f64::INFINITY) + }; + let d = if let Ok(d) = i64::try_from(den.as_ibig().clone()) { + d as f64 + } else { + den.as_ibig().to_string().parse::().unwrap_or(f64::INFINITY) + }; + n / d + } + } + } + + /// Construct from an `f64`. Converts through string representation to + /// capture the exact decimal shown by Rust's `Display` formatting. + pub fn from_f64(v: f64) -> Number { + if v.fract() == 0.0 && v.abs() < 1e18 { + Number::Integer(IBig::from(v as i64)) + } else { + // Use Display which gives a shortest-round-trip representation + let s = format!("{}", v); + Number::parse(&s).unwrap_or_else(|| Number::Integer(IBig::from(0))) + } + } + + // ----------------------------------------------------------------------- + // Arithmetic helpers + // ----------------------------------------------------------------------- + + /// Integer exponentiation: `self ^ exp`. + /// + /// Returns `None` for non-integer exponents (fractional powers are not yet + /// supported in exact arithmetic). + pub fn pow(&self, exp: &Number) -> Option { + match exp { + Number::Integer(e) => { + if *e < IBig::from(0) { + // Negative exponent: compute base^|e| then invert + let abs_exp: usize = (-e.clone()).try_into().ok()?; + let base_r = self.to_rational(); + let result = rational_pow(&base_r, abs_exp); + let (num, den) = result.into_parts(); + let inverted = RBig::from_parts( + den.as_ibig().clone().into(), + num.try_into().ok()?, + ); + Some(Number::from_rational(inverted)) + } else { + let exp_usize: usize = e.clone().try_into().ok()?; + match self { + Number::Integer(base) => { + Some(Number::Integer(base.clone().pow(exp_usize))) + } + Number::Rational(base) => { + Some(Number::from_rational(rational_pow(base, exp_usize))) + } + } + } + } + Number::Rational(_) => None, + } + } + + /// Factorial. Only defined for non-negative integers. + pub fn factorial(&self) -> Option { + match self { + Number::Integer(n) => { + if *n < IBig::from(0) { + return None; + } + let n_usize: usize = n.clone().try_into().ok()?; + let mut result = IBig::from(1); + for i in 2..=n_usize { + result *= IBig::from(i); + } + Some(Number::Integer(result)) + } + Number::Rational(_) => None, + } + } + + /// Returns `true` if the value is zero. + pub fn is_zero(&self) -> bool { + match self { + Number::Integer(i) => *i == IBig::from(0), + Number::Rational(r) => { + let (num, _) = r.clone().into_parts(); + num == IBig::from(0) + } + } + } + + /// Returns `true` if this is an exact integer value (denominator == 1). + pub fn is_integer(&self) -> bool { + matches!(self, Number::Integer(_)) + } + + /// Format with a given cap on significant digits for non-terminating results. + pub fn format(&self, max_sig_digits: usize) -> String { + match self { + Number::Integer(i) => i.to_string(), + Number::Rational(r) => format_rational(r, max_sig_digits), + } + } +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/// Raise a rational to a non-negative integer power. +fn rational_pow(base: &RBig, exp: usize) -> RBig { + if exp == 0 { + return RBig::from(IBig::from(1)); + } + let (num, den) = base.clone().into_parts(); + let num_pow = num.pow(exp); + let den_pow = den.as_ibig().clone().pow(exp); + RBig::from_parts(num_pow.into(), den_pow.try_into().unwrap()) +} + +/// Format a rational as a decimal string. +/// +/// If the decimal terminates (denominator has only factors 2 and 5), the exact +/// value is printed. Otherwise, `max_sig_digits` significant digits are shown +/// with rounding. +fn format_rational(r: &RBig, max_sig_digits: usize) -> String { + let (num, den) = r.clone().into_parts(); + let den_ibig = den.as_ibig().clone(); + + let is_negative = num < IBig::from(0); + let abs_num = if is_negative { -num.clone() } else { num.clone() }; + + if is_terminating_decimal(&den_ibig) { + format_terminating(&abs_num, &den_ibig, is_negative) + } else { + format_non_terminating(&abs_num, &den_ibig, max_sig_digits, is_negative) + } +} + +fn format_terminating(abs_num: &IBig, den: &IBig, is_negative: bool) -> String { + let quotient = abs_num / den; + let remainder = abs_num % den; + + if remainder == IBig::from(0) { + let s = quotient.to_string(); + return if is_negative { format!("-{}", s) } else { s }; + } + + // Long division for the fractional part + let mut frac_digits = String::new(); + let mut rem = remainder; + loop { + rem *= IBig::from(10); + let digit = &rem / den; + rem = &rem % den; + frac_digits.push_str(&digit.to_string()); + if rem == IBig::from(0) { + break; + } + } + + let s = format!("{}.{}", quotient, frac_digits); + if is_negative { format!("-{}", s) } else { s } +} + +fn format_non_terminating( + abs_num: &IBig, + den: &IBig, + max_sig_digits: usize, + is_negative: bool, +) -> String { + let quotient = abs_num / den; + let remainder = abs_num % den; + let int_str = quotient.to_string(); + + let int_sig_digits = if quotient == IBig::from(0) { 0 } else { int_str.len() }; + let frac_digits_needed = if int_sig_digits >= max_sig_digits { + 1 // always show at least 1 fractional digit + } else { + max_sig_digits - int_sig_digits + }; + + let mut frac_digits = String::new(); + let mut rem = remainder; + let mut sig_count = int_sig_digits; + + for _ in 0..frac_digits_needed + 100 { + // +100 headroom for leading zeros + rem *= IBig::from(10); + let digit = &rem / den; + rem = &rem % den; + let d = digit.to_string(); + frac_digits.push_str(&d); + + if sig_count > 0 || d != "0" { + sig_count += 1; + } + + if sig_count >= max_sig_digits { + // Round based on next digit + rem *= IBig::from(10); + let next_digit = &rem / den; + if next_digit >= IBig::from(5) { + frac_digits = round_up_decimal_str(&frac_digits); + } + break; + } + } + + // Trim trailing zeros after rounding + let frac_trimmed = frac_digits.trim_end_matches('0'); + let s = if frac_trimmed.is_empty() { + int_str + } else { + format!("{}.{}", int_str, frac_trimmed) + }; + if is_negative { format!("-{}", s) } else { s } +} + +/// True when the denominator has only factors of 2 and 5 (decimal terminates). +fn is_terminating_decimal(den: &IBig) -> bool { + let mut d = den.clone(); + while &d % IBig::from(2) == IBig::from(0) { + d /= IBig::from(2); + } + while &d % IBig::from(5) == IBig::from(0) { + d /= IBig::from(5); + } + d == IBig::from(1) +} + +/// Increment the last digit of a decimal-fraction digit string, carrying as needed. +fn round_up_decimal_str(s: &str) -> String { + let mut chars: Vec = s.bytes().collect(); + let mut carry = true; + for i in (0..chars.len()).rev() { + if carry { + if chars[i] == b'9' { + chars[i] = b'0'; + } else { + chars[i] += 1; + carry = false; + } + } + } + let result: String = chars.iter().map(|&b| b as char).collect(); + if carry { + format!("1{}", result) + } else { + result + } +} + +// =========================================================================== +// Trait implementations +// =========================================================================== + +impl PartialEq for Number { + fn eq(&self, other: &Self) -> bool { + self.to_rational() == other.to_rational() + } +} + +impl Eq for Number {} + +impl PartialOrd for Number { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Number { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.to_rational().cmp(&other.to_rational()) + } +} + +impl fmt::Display for Number { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.format(DEFAULT_PRECISION)) + } +} + +// -- Conversions from primitives ------------------------------------------- + +impl From for Number { + fn from(v: i64) -> Self { + Number::Integer(IBig::from(v)) + } +} + +impl From for Number { + fn from(v: i32) -> Self { + Number::Integer(IBig::from(v)) + } +} + +impl From for Number { + fn from(v: u64) -> Self { + Number::Integer(IBig::from(v)) + } +} + +impl From for Number { + fn from(v: f64) -> Self { + Number::from_f64(v) + } +} + +// -- Arithmetic operators --------------------------------------------------- + +impl std::ops::Add for Number { + type Output = Number; + fn add(self, rhs: Self) -> Self::Output { + Number::from_rational(self.to_rational() + rhs.to_rational()) + } +} + +impl std::ops::Sub for Number { + type Output = Number; + fn sub(self, rhs: Self) -> Self::Output { + Number::from_rational(self.to_rational() - rhs.to_rational()) + } +} + +impl std::ops::Mul for Number { + type Output = Number; + fn mul(self, rhs: Self) -> Self::Output { + Number::from_rational(self.to_rational() * rhs.to_rational()) + } +} + +impl std::ops::Div for Number { + type Output = Option; + fn div(self, rhs: Self) -> Self::Output { + if rhs.is_zero() { + return None; + } + Some(Number::from_rational(self.to_rational() / rhs.to_rational())) + } +} + +impl std::ops::Neg for Number { + type Output = Number; + fn neg(self) -> Self::Output { + match self { + Number::Integer(i) => Number::Integer(-i), + Number::Rational(r) => Number::Rational(-r), + } + } +} + +impl std::ops::Rem for Number { + type Output = Option; + fn rem(self, rhs: Self) -> Self::Output { + if rhs.is_zero() { + return None; + } + match (&self, &rhs) { + (Number::Integer(a), Number::Integer(b)) => { + Some(Number::Integer(a % b)) + } + _ => { + // a % b = a - b * floor(a / b) + let a = self.to_rational(); + let b = rhs.to_rational(); + let div = &a / &b; + let (num, den) = div.into_parts(); + let floor_div = &num / den.as_ibig(); + let result = a - b * RBig::from(floor_div); + Some(Number::from_rational(result)) + } + } + } +} + +// -- Serde ------------------------------------------------------------------ +// +// We serialize as a decimal string so that JSON consumers always get a +// human-readable (and exact) representation, regardless of magnitude. + +impl Serialize for Number { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Number { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Number::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("invalid number: {}", s))) + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + // -- Parsing ------------------------------------------------------------ + + #[test] + fn test_parse_integer() { + let n = Number::parse("42").unwrap(); + assert_eq!(n, Number::from(42i64)); + } + + #[test] + fn test_parse_negative_integer() { + let n = Number::parse("-7").unwrap(); + assert_eq!(n, Number::from(-7i64)); + } + + #[test] + fn test_parse_decimal() { + let n = Number::parse("3.14").unwrap(); + assert_eq!(n.to_string(), "3.14"); + } + + #[test] + fn test_parse_zero_point_one() { + let n = Number::parse("0.1").unwrap(); + assert!(matches!(n, Number::Rational(_))); + assert_eq!(n.to_string(), "0.1"); + } + + #[test] + fn test_parse_empty_returns_none() { + assert!(Number::parse("").is_none()); + assert!(Number::parse(" ").is_none()); + } + + // -- Exact decimal arithmetic ------------------------------------------- + + #[test] + fn test_exact_decimal_addition() { + let a = Number::parse("0.1").unwrap(); + let b = Number::parse("0.2").unwrap(); + assert_eq!((a + b).to_string(), "0.3"); + } + + #[test] + fn test_ten_tenths_equal_one() { + let tenth = Number::parse("0.1").unwrap(); + let mut sum = Number::from(0i64); + for _ in 0..10 { + sum = sum + tenth.clone(); + } + assert_eq!(sum, Number::from(1i64)); + } + + #[test] + fn test_financial_multiplication() { + let amount = Number::parse("1000000.01").unwrap(); + let days = Number::from(365i64); + assert_eq!((amount * days).to_string(), "365000003.65"); + } + + // -- Large numbers ------------------------------------------------------ + + #[test] + fn test_factorial_small() { + assert_eq!(Number::from(5i64).factorial().unwrap(), Number::from(120i64)); + } + + #[test] + fn test_factorial_100() { + let result = Number::from(100i64).factorial().unwrap(); + let s = result.to_string(); + assert_eq!(s.len(), 158); // 100! has 158 digits + assert!(s.starts_with("933262154439441526816992388562667004")); + } + + #[test] + fn test_pow_2_to_1000() { + let result = Number::from(2i64).pow(&Number::from(1000i64)).unwrap(); + let s = result.to_string(); + assert!(s.starts_with("1071508607186267320948425049060001")); + assert_eq!(s.len(), 302); + } + + #[test] + fn test_negative_exponent() { + let result = Number::from(2i64).pow(&Number::from(-3i64)).unwrap(); + assert_eq!(result.to_string(), "0.125"); + } + + // -- Division & formatting ---------------------------------------------- + + #[test] + fn test_division_precision() { + let result = (Number::from(1i64) / Number::from(3i64)).unwrap(); + let s = result.format(DEFAULT_PRECISION); + assert!(s.starts_with("0.")); + let after_dot = &s[2..]; + assert!(after_dot.len() >= 30); + assert!(after_dot.chars().all(|c| c == '3')); + } + + #[test] + fn test_integer_division_exact() { + let result = (Number::from(10i64) / Number::from(2i64)).unwrap(); + assert_eq!(result, Number::from(5i64)); + assert!(result.is_integer()); + } + + #[test] + fn test_division_by_zero() { + assert!((Number::from(1i64) / Number::from(0i64)).is_none()); + } + + // -- Modulo ------------------------------------------------------------- + + #[test] + fn test_modulo_integer() { + let result = (Number::from(10i64) % Number::from(3i64)).unwrap(); + assert_eq!(result, Number::from(1i64)); + } + + #[test] + fn test_modulo_by_zero() { + assert!((Number::from(5i64) % Number::from(0i64)).is_none()); + } + + // -- Negation & ordering ------------------------------------------------ + + #[test] + fn test_negation() { + assert_eq!(-Number::from(5i64), Number::from(-5i64)); + } + + #[test] + fn test_ordering() { + let a = Number::from(3i64); + let b = Number::parse("3.5").unwrap(); + assert!(a < b); + } + + // -- f64 round-trip ----------------------------------------------------- + + #[test] + fn test_to_f64() { + assert_eq!(Number::from(42i64).to_f64(), 42.0); + let half = (Number::from(1i64) / Number::from(2i64)).unwrap(); + assert_eq!(half.to_f64(), 0.5); + } + + #[test] + fn test_from_f64() { + let n = Number::from_f64(3.14); + assert_eq!(n.to_string(), "3.14"); + } + + // -- Serde round-trip --------------------------------------------------- + + #[test] + fn test_serde_roundtrip() { + let n = Number::parse("123.456").unwrap(); + let json = serde_json::to_string(&n).unwrap(); + assert_eq!(json, "\"123.456\""); + let back: Number = serde_json::from_str(&json).unwrap(); + assert_eq!(back, n); + } + + #[test] + fn test_serde_integer_roundtrip() { + let n = Number::from(42i64); + let json = serde_json::to_string(&n).unwrap(); + assert_eq!(json, "\"42\""); + let back: Number = serde_json::from_str(&json).unwrap(); + assert_eq!(back, n); + } +} diff --git a/calcpad-engine/src/parser.rs b/calcpad-engine/src/parser.rs new file mode 100644 index 0000000..44bbac8 --- /dev/null +++ b/calcpad-engine/src/parser.rs @@ -0,0 +1,439 @@ +use crate::ast::*; +use crate::error::ParseError; +use crate::span::Span; +use crate::token::{Operator, Token, TokenKind}; + +/// Precedence levels for Pratt parsing (higher = tighter binding). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Precedence { + None = 0, + Assignment = 1, + Conversion = 2, + Comparison = 3, + Sum = 4, + Product = 5, + Exponent = 6, + Unary = 7, +} + +/// A recursive-descent Pratt parser that consumes a token stream and produces an AST. +pub struct Parser { + tokens: Vec, + pos: usize, +} + +impl Parser { + pub fn new(tokens: Vec) -> Self { + Self { tokens, pos: 0 } + } + + /// Parse the entire token stream into an expression. + pub fn parse(mut self) -> Result { + if self.at_end() { + return Err(ParseError::new( + "unexpected end of input: expected an expression", + Span::new(0, 0), + )); + } + + let expr = self.parse_expr(Precedence::None)?; + + // Allow trailing comments and Eof + while self.check(&TokenKind::Eof) || matches!(self.peek().kind, TokenKind::Comment(_)) { + self.advance(); + if self.pos >= self.tokens.len() { + break; + } + } + + Ok(expr) + } + + fn parse_expr(&mut self, min_prec: Precedence) -> Result { + let mut left = self.parse_prefix()?; + + loop { + if self.at_end() { + break; + } + + let tok = self.peek(); + if matches!(tok.kind, TokenKind::Eof | TokenKind::Comment(_)) { + break; + } + + // Check for assignment: if left is Identifier and we see `=` + if min_prec <= Precedence::Assignment { + if let ExprKind::Identifier(ref name) = left.node { + if self.check(&TokenKind::Assign) { + let name = name.clone(); + let assign_span_start = left.span.start; + self.advance(); // consume `=` + let value = self.parse_expr(Precedence::Assignment)?; + let span = Span::new(assign_span_start, value.span.end); + left = Spanned::new( + ExprKind::Assignment { + name, + value: Box::new(value), + }, + span, + ); + continue; + } + } + } + + // Percent operation: `expr +/- N%` + if matches!( + self.peek().kind, + TokenKind::Percent(_) + ) { + // Look back at the previous operator we might have consumed + // Actually, percent is handled as infix after +/- in the binary op path + break; + } + + let prec = self.infix_precedence(); + if prec <= min_prec { + break; + } + + left = self.parse_infix(left, prec)?; + } + + Ok(left) + } + + fn parse_prefix(&mut self) -> Result { + let tok = self.peek().clone(); + match &tok.kind { + TokenKind::Number(val) => { + let val = *val; + let span = tok.span; + self.advance(); + // Check for unit suffix + if !self.at_end() { + if let TokenKind::Unit(unit) = &self.peek().kind { + let unit = unit.clone(); + let unit_span = self.peek().span; + self.advance(); + return Ok(Spanned::new( + ExprKind::UnitNumber { value: val, unit }, + span.merge(unit_span), + )); + } + } + Ok(Spanned::new(ExprKind::Number(val), span)) + } + + TokenKind::CurrencySymbol(sym) => { + let currency = symbol_to_currency(sym); + let sym_span = tok.span; + self.advance(); + // Expect a number after currency symbol + if self.at_end() { + return Err(ParseError::new( + "expected number after currency symbol", + sym_span, + )); + } + let amount_expr = self.parse_prefix()?; + let amount = extract_number(&amount_expr)?; + let span = sym_span.merge(amount_expr.span); + Ok(Spanned::new( + ExprKind::CurrencyValue { amount, currency }, + span, + )) + } + + TokenKind::Op(Operator::Subtract) => { + let op_span = tok.span; + self.advance(); + let operand = self.parse_expr(Precedence::Unary)?; + let span = op_span.merge(operand.span); + Ok(Spanned::new(ExprKind::UnaryNeg(Box::new(operand)), span)) + } + + TokenKind::LParen => { + let open_span = tok.span; + self.advance(); + let inner = self.parse_expr(Precedence::None)?; + if !self.check(&TokenKind::RParen) { + return Err(ParseError::new( + "expected closing parenthesis ')'", + self.current_span(), + )); + } + let close_span = self.peek().span; + self.advance(); + // Re-wrap with the full span including parens + Ok(Spanned::new(inner.node, open_span.merge(close_span))) + } + + TokenKind::Identifier(name) => { + let name = name.clone(); + let span = tok.span; + self.advance(); + Ok(Spanned::new(ExprKind::Identifier(name), span)) + } + + _ => Err(ParseError::new( + format!("unexpected token: {:?}", tok.kind), + tok.span, + )), + } + } + + fn parse_infix(&mut self, left: Expr, prec: Precedence) -> Result { + let tok = self.peek().clone(); + match &tok.kind { + TokenKind::Op(op) => { + let bin_op = operator_to_binop(op); + self.advance(); + + // Check for percent operation: `expr +/- N%` + if matches!(bin_op, BinOp::Add | BinOp::Sub) { + if let Some(pct) = self.try_parse_percent() { + let pct_op = if matches!(bin_op, BinOp::Add) { + PercentOp::Add + } else { + PercentOp::Sub + }; + let span = left.span.merge(Span::new( + self.tokens[self.pos - 1].span.start, + self.tokens[self.pos - 1].span.end, + )); + return Ok(Spanned::new( + ExprKind::PercentOp { + op: pct_op, + base: Box::new(left), + percentage: pct, + }, + span, + )); + } + } + + let right = self.parse_expr(prec)?; + let span = left.span.merge(right.span); + Ok(Spanned::new( + ExprKind::BinaryOp { + op: bin_op, + left: Box::new(left), + right: Box::new(right), + }, + span, + )) + } + + TokenKind::Greater | TokenKind::Less | TokenKind::GreaterEq + | TokenKind::LessEq | TokenKind::Equal | TokenKind::NotEqual => { + let cmp_op = match &tok.kind { + TokenKind::Greater => CmpOp::Gt, + TokenKind::Less => CmpOp::Lt, + TokenKind::GreaterEq => CmpOp::Gte, + TokenKind::LessEq => CmpOp::Lte, + TokenKind::Equal => CmpOp::Eq, + TokenKind::NotEqual => CmpOp::Neq, + _ => unreachable!(), + }; + self.advance(); + let right = self.parse_expr(Precedence::Comparison)?; + let span = left.span.merge(right.span); + Ok(Spanned::new( + ExprKind::Comparison { + op: cmp_op, + left: Box::new(left), + right: Box::new(right), + }, + span, + )) + } + + TokenKind::In => { + self.advance(); + // Parse conversion target (keyword or identifier) + let target = self.parse_conversion_target()?; + let span = left.span.merge(self.tokens[self.pos - 1].span); + Ok(Spanned::new( + ExprKind::Conversion { + expr: Box::new(left), + target_currency: target, + }, + span, + )) + } + + _ => Err(ParseError::new( + format!("unexpected infix token: {:?}", tok.kind), + tok.span, + )), + } + } + + fn try_parse_percent(&mut self) -> Option { + if self.at_end() { + return None; + } + if let TokenKind::Percent(val) = &self.peek().kind { + let val = *val; + self.advance(); + // Skip optional "discount" or "off" keyword + if !self.at_end() { + if let TokenKind::Keyword(kw) = &self.peek().kind { + if kw == "discount" || kw == "off" { + self.advance(); + } + } + } + Some(val) + } else { + None + } + } + + fn parse_conversion_target(&mut self) -> Result { + if self.at_end() { + return Err(ParseError::new( + "expected conversion target after 'in'", + self.current_span(), + )); + } + let tok = self.peek().clone(); + match &tok.kind { + TokenKind::Keyword(kw) => { + let target = keyword_to_currency(kw); + self.advance(); + Ok(target) + } + TokenKind::Identifier(id) => { + let target = id.to_uppercase(); + self.advance(); + Ok(target) + } + TokenKind::Unit(u) => { + let target = u.clone(); + self.advance(); + Ok(target) + } + _ => Err(ParseError::new( + format!("expected conversion target, got {:?}", tok.kind), + tok.span, + )), + } + } + + fn infix_precedence(&self) -> Precedence { + if self.at_end() { + return Precedence::None; + } + match &self.peek().kind { + TokenKind::Op(Operator::Add) | TokenKind::Op(Operator::Subtract) => Precedence::Sum, + TokenKind::Op(Operator::Multiply) + | TokenKind::Op(Operator::Divide) + | TokenKind::Op(Operator::Modulo) => Precedence::Product, + TokenKind::Op(Operator::Power) => Precedence::Exponent, + TokenKind::Greater + | TokenKind::Less + | TokenKind::GreaterEq + | TokenKind::LessEq + | TokenKind::Equal + | TokenKind::NotEqual => Precedence::Comparison, + TokenKind::In => Precedence::Conversion, + _ => Precedence::None, + } + } + + fn peek(&self) -> &Token { + &self.tokens[self.pos] + } + + fn check(&self, kind: &TokenKind) -> bool { + if self.at_end() { + return false; + } + std::mem::discriminant(&self.peek().kind) == std::mem::discriminant(kind) + } + + fn advance(&mut self) { + if self.pos < self.tokens.len() { + self.pos += 1; + } + } + + fn at_end(&self) -> bool { + self.pos >= self.tokens.len() + } + + fn current_span(&self) -> Span { + if self.at_end() { + if let Some(last) = self.tokens.last() { + last.span + } else { + Span::new(0, 0) + } + } else { + self.peek().span + } + } +} + +/// Top-level parse function. +pub fn parse(tokens: Vec) -> Result { + // Filter out text-only token streams + let has_expr = tokens.iter().any(|t| { + !matches!( + t.kind, + TokenKind::Text(_) | TokenKind::Comment(_) | TokenKind::Eof + ) + }); + if !has_expr { + return Err(ParseError::new("no expression to parse", Span::new(0, 0))); + } + Parser::new(tokens).parse() +} + +fn operator_to_binop(op: &Operator) -> BinOp { + match op { + Operator::Add => BinOp::Add, + Operator::Subtract => BinOp::Sub, + Operator::Multiply => BinOp::Mul, + Operator::Divide => BinOp::Div, + Operator::Power => BinOp::Pow, + Operator::Modulo => BinOp::Mod, + } +} + +fn symbol_to_currency(sym: &str) -> String { + match sym { + "$" => "USD".to_string(), + "€" => "EUR".to_string(), + "£" => "GBP".to_string(), + "¥" => "JPY".to_string(), + "R$" => "BRL".to_string(), + other => other.to_string(), + } +} + +fn keyword_to_currency(kw: &str) -> String { + match kw.to_lowercase().as_str() { + "euro" | "eur" => "EUR".to_string(), + "usd" | "dollar" | "dollars" => "USD".to_string(), + "gbp" | "pound" | "pounds" => "GBP".to_string(), + other => other.to_uppercase(), + } +} + +fn extract_number(expr: &Expr) -> Result { + match &expr.node { + ExprKind::Number(v) => Ok(*v), + ExprKind::UnaryNeg(inner) => { + let v = extract_number(inner)?; + Ok(-v) + } + _ => Err(ParseError::new( + "expected numeric value", + expr.span, + )), + } +} diff --git a/calcpad-engine/src/pipeline.rs b/calcpad-engine/src/pipeline.rs new file mode 100644 index 0000000..2253e1c --- /dev/null +++ b/calcpad-engine/src/pipeline.rs @@ -0,0 +1,40 @@ +use crate::context::EvalContext; +use crate::interpreter::evaluate; +use crate::lexer::tokenize; +use crate::parser::parse; +use crate::span::Span; +use crate::types::CalcResult; + +/// Evaluate a single line of input and return the result. +pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult { + let trimmed = input.trim(); + if trimmed.is_empty() { + return CalcResult::error("empty input", Span::new(0, 0)); + } + + let tokens = tokenize(trimmed); + + // Check for text-only or comment-only lines + let has_expr = tokens.iter().any(|t| { + !matches!( + t.kind, + crate::token::TokenKind::Text(_) + | crate::token::TokenKind::Comment(_) + | crate::token::TokenKind::Eof + ) + }); + if !has_expr { + return CalcResult::error("no expression found", Span::new(0, trimmed.len())); + } + + match parse(tokens) { + Ok(expr) => evaluate(&expr, ctx), + Err(e) => CalcResult::error(&e.message, e.span), + } +} + +/// Evaluate multiple lines of input, sharing context across lines. +/// Variable assignments on earlier lines are visible to later lines. +pub fn eval_sheet(lines: &[&str], ctx: &mut EvalContext) -> Vec { + lines.iter().map(|line| eval_line(line, ctx)).collect() +} diff --git a/calcpad-engine/src/sheet_context.rs b/calcpad-engine/src/sheet_context.rs new file mode 100644 index 0000000..b3e12b8 --- /dev/null +++ b/calcpad-engine/src/sheet_context.rs @@ -0,0 +1,683 @@ +use crate::ast::{Expr, ExprKind}; +use crate::context::EvalContext; +use crate::interpreter; +use crate::lexer; +use crate::parser; +use crate::span::Span; +use crate::types::CalcResult; +use std::collections::{HashMap, HashSet}; + +/// A parsed line in the sheet. +#[derive(Debug, Clone)] +struct LineEntry { + /// The raw source text. + source: String, + /// The parsed AST, or None if parse failed. + parsed: Option, + /// The parse error, if any. + parse_error: Option, + /// Variable name defined by this line (if assignment). + defines_var: Option, + /// Variable names referenced by this line. + references_vars: Vec, + /// Whether this line is a non-calculable text/comment line. + is_text: bool, +} + +/// SheetContext holds all evaluation state for a multi-line sheet. +/// +/// It manages variables, line results, dependency graphs, and supports +/// selective re-evaluation when lines change. +#[derive(Debug, Clone)] +pub struct SheetContext { + /// Lines stored by index. Sparse -- not all indices need to be filled. + lines: HashMap, + /// Cached results per line from the last evaluation. + results: HashMap, + /// The maximum line index seen (for iteration order). + max_line: usize, + /// Tracks which lines have been modified since last eval. + dirty_lines: HashSet, + /// Whether a full evaluation has been performed at least once. + has_evaluated: bool, +} + +impl SheetContext { + /// Create a new, empty SheetContext. + pub fn new() -> Self { + SheetContext { + lines: HashMap::new(), + results: HashMap::new(), + max_line: 0, + dirty_lines: HashSet::new(), + has_evaluated: false, + } + } + + /// Set (or replace) a line at the given index. + pub fn set_line(&mut self, index: usize, source: &str) { + if index > self.max_line { + self.max_line = index; + } + + let trimmed = source.trim(); + + // Tokenize and parse through the real engine pipeline + let tokens = lexer::tokenize(trimmed); + + // Check if this is a text-only or comment-only line + let is_text = !tokens.iter().any(|t| { + !matches!( + t.kind, + crate::token::TokenKind::Text(_) + | crate::token::TokenKind::Comment(_) + | crate::token::TokenKind::Eof + ) + }); + + let (parsed, parse_error) = if is_text || trimmed.is_empty() { + (None, None) + } else { + match parser::parse(tokens) { + Ok(expr) => (Some(expr), None), + Err(e) => (None, Some(e.message)), + } + }; + + let defines_var = parsed.as_ref().and_then(defined_variable); + let references_vars = parsed + .as_ref() + .map(referenced_variables) + .unwrap_or_default(); + + let entry = LineEntry { + source: source.to_string(), + parsed, + parse_error, + defines_var, + references_vars, + is_text, + }; + + self.lines.insert(index, entry); + self.dirty_lines.insert(index); + } + + /// Remove a line at the given index. + pub fn remove_line(&mut self, index: usize) { + self.lines.remove(&index); + self.results.remove(&index); + self.dirty_lines.insert(index); + } + + /// Get the number of lines in the sheet. + pub fn line_count(&self) -> usize { + self.lines.len() + } + + /// Check if the sheet has any variables defined. + pub fn has_variables(&self) -> bool { + self.lines.values().any(|e| e.defines_var.is_some()) + } + + /// Evaluate all lines in the sheet, returning results in line-index order. + /// + /// This method performs dependency analysis and selective re-evaluation: + /// - Lines whose dependencies haven't changed are not recomputed. + /// - Circular dependencies are detected and reported as errors. + pub fn eval(&mut self) -> Vec { + let line_indices = self.sorted_line_indices(); + + if line_indices.is_empty() { + self.has_evaluated = true; + self.dirty_lines.clear(); + return Vec::new(); + } + + // Build the dependency graph + let var_to_line = self.build_var_to_line_map(&line_indices); + + // Detect circular dependencies + let circular_lines = self.detect_circular_deps(&line_indices, &var_to_line); + + // Determine which lines need re-evaluation + let lines_to_eval = if !self.has_evaluated { + // First eval: evaluate everything + line_indices.iter().copied().collect::>() + } else { + self.compute_dirty_set(&line_indices, &var_to_line) + }; + + // Build a shared EvalContext and evaluate in order. + // We rebuild the context for the full pass so variables propagate correctly. + let mut ctx = EvalContext::new(); + + for &idx in &line_indices { + if circular_lines.contains(&idx) { + let result = CalcResult::error( + "Circular dependency detected", + Span::new(0, 1), + ); + self.results.insert(idx, result); + continue; + } + + let entry = &self.lines[&idx]; + + // Text/empty lines produce no result -- skip them + if entry.is_text || entry.source.trim().is_empty() { + let result = CalcResult::error("no expression found", Span::new(0, entry.source.len())); + self.results.insert(idx, result); + continue; + } + + if lines_to_eval.contains(&idx) { + // Evaluate this line + if let Some(ref expr) = entry.parsed { + let result = interpreter::evaluate(expr, &mut ctx); + self.results.insert(idx, result); + } else { + // Parse error + let err_msg = entry + .parse_error + .as_deref() + .unwrap_or("Parse error"); + let result = CalcResult::error(err_msg, Span::new(0, 1)); + self.results.insert(idx, result); + } + } else { + // Reuse cached result, but still replay variable definitions into ctx + if let Some(cached) = self.results.get(&idx) { + if let Some(ref var_name) = entry.defines_var { + ctx.set_variable(var_name, cached.clone()); + } + } + } + } + + self.has_evaluated = true; + self.dirty_lines.clear(); + + // Return results in line order + line_indices + .iter() + .map(|idx| { + self.results + .get(idx) + .cloned() + .unwrap_or_else(|| CalcResult::error("No result", Span::new(0, 1))) + }) + .collect() + } + + /// Get the dependency graph as a map of line index -> set of line indices it depends on. + pub fn dependency_graph(&self) -> HashMap> { + let line_indices = self.sorted_line_indices(); + let var_to_line = self.build_var_to_line_map(&line_indices); + let mut graph: HashMap> = HashMap::new(); + + for &idx in &line_indices { + let mut deps = HashSet::new(); + if let Some(entry) = self.lines.get(&idx) { + for var in &entry.references_vars { + if let Some(&def_line) = var_to_line.get(var) { + deps.insert(def_line); + } + } + } + graph.insert(idx, deps); + } + + graph + } + + // --- Internal helpers --- + + fn sorted_line_indices(&self) -> Vec { + let mut indices: Vec = self.lines.keys().copied().collect(); + indices.sort(); + indices + } + + /// Build a map from variable name to the line index that defines it. + fn build_var_to_line_map(&self, line_indices: &[usize]) -> HashMap { + let mut map = HashMap::new(); + for &idx in line_indices { + if let Some(entry) = self.lines.get(&idx) { + if let Some(ref var_name) = entry.defines_var { + map.insert(var_name.clone(), idx); + } + } + } + map + } + + /// Detect circular dependencies using DFS cycle detection. + fn detect_circular_deps( + &self, + line_indices: &[usize], + var_to_line: &HashMap, + ) -> HashSet { + let mut circular = HashSet::new(); + + // Build adjacency list: line -> lines it depends on + let mut adj: HashMap> = HashMap::new(); + for &idx in line_indices { + let mut deps = Vec::new(); + if let Some(entry) = self.lines.get(&idx) { + for var in &entry.references_vars { + if let Some(&dep_line) = var_to_line.get(var) { + deps.push(dep_line); + } + } + } + adj.insert(idx, deps); + } + + // DFS for each node + let mut visited = HashSet::new(); + let mut in_stack = HashSet::new(); + + for &idx in line_indices { + if !visited.contains(&idx) { + Self::dfs_cycle(idx, &adj, &mut visited, &mut in_stack, &mut circular); + } + } + + circular + } + + fn dfs_cycle( + node: usize, + adj: &HashMap>, + visited: &mut HashSet, + in_stack: &mut HashSet, + circular: &mut HashSet, + ) { + visited.insert(node); + in_stack.insert(node); + + if let Some(deps) = adj.get(&node) { + for &dep in deps { + if !visited.contains(&dep) { + Self::dfs_cycle(dep, adj, visited, in_stack, circular); + } else if in_stack.contains(&dep) { + // Found a cycle -- mark both nodes + circular.insert(dep); + circular.insert(node); + } + } + } + + in_stack.remove(&node); + } + + /// Compute the set of lines that need re-evaluation based on dirty lines + /// and their dependents (transitive closure). + fn compute_dirty_set( + &self, + line_indices: &[usize], + var_to_line: &HashMap, + ) -> HashSet { + let mut dirty = self.dirty_lines.clone(); + + // Build reverse map: line -> lines that depend on it + let mut dependents: HashMap> = HashMap::new(); + for &idx in line_indices { + if let Some(entry) = self.lines.get(&idx) { + for var in &entry.references_vars { + if let Some(&def_line) = var_to_line.get(var) { + dependents + .entry(def_line) + .or_default() + .push(idx); + } + } + } + } + + // BFS to propagate dirtiness + let mut queue: Vec = dirty.iter().copied().collect(); + while let Some(line) = queue.pop() { + if let Some(deps) = dependents.get(&line) { + for &dep in deps { + if dirty.insert(dep) { + queue.push(dep); + } + } + } + } + + dirty + } +} + +impl Default for SheetContext { + fn default() -> Self { + Self::new() + } +} + +// --- AST helper functions --- + +/// Extract the variable name defined by an assignment expression. +fn defined_variable(expr: &Expr) -> Option { + match &expr.node { + ExprKind::Assignment { name, .. } => Some(name.clone()), + _ => None, + } +} + +/// Collect all variable names referenced (but not defined) by an expression. +fn referenced_variables(expr: &Expr) -> Vec { + let mut vars = Vec::new(); + collect_references(&expr.node, &mut vars); + vars +} + +fn collect_references(node: &ExprKind, vars: &mut Vec) { + match node { + ExprKind::Identifier(name) => { + if !vars.contains(name) { + vars.push(name.clone()); + } + } + ExprKind::Assignment { value, .. } => { + collect_references(&value.node, vars); + } + ExprKind::BinaryOp { left, right, .. } => { + collect_references(&left.node, vars); + collect_references(&right.node, vars); + } + ExprKind::UnaryNeg(inner) => { + collect_references(&inner.node, vars); + } + ExprKind::PercentOp { base, .. } => { + collect_references(&base.node, vars); + } + ExprKind::Conversion { expr, .. } => { + collect_references(&expr.node, vars); + } + ExprKind::DateRange { from, to } => { + collect_references(&from.node, vars); + collect_references(&to.node, vars); + } + ExprKind::Comparison { left, right, .. } => { + collect_references(&left.node, vars); + collect_references(&right.node, vars); + } + // Leaf nodes with no variable references + ExprKind::Number(_) + | ExprKind::UnitNumber { .. } + | ExprKind::CurrencyValue { .. } + | ExprKind::Boolean(_) + | ExprKind::DateLiteral { .. } + | ExprKind::Today + | ExprKind::Duration { .. } => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{CalcValue, ResultType}; + + // === AC: New SheetContext is empty === + #[test] + fn test_new_sheet_context_is_empty() { + let ctx = SheetContext::new(); + assert_eq!(ctx.line_count(), 0); + assert!(!ctx.has_variables()); + let dep_graph = ctx.dependency_graph(); + assert!(dep_graph.is_empty()); + } + + // === AC: Basic two-line evaluation === + #[test] + fn test_basic_two_line_eval() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "x = 10"); + ctx.set_line(1, "x * 2"); + let results = ctx.eval(); + assert_eq!(results.len(), 2); + assert_eq!(results[0].value, CalcValue::Number { value: 10.0 }); + assert_eq!(results[1].value, CalcValue::Number { value: 20.0 }); + } + + // === AC: Variable update propagates === + #[test] + fn test_variable_update_propagates() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "x = 10"); + ctx.set_line(2, "x"); + let results = ctx.eval(); + assert_eq!(results[1].value, CalcValue::Number { value: 10.0 }); + + // Change x to 20 + ctx.set_line(0, "x = 20"); + let results = ctx.eval(); + assert_eq!(results[0].value, CalcValue::Number { value: 20.0 }); + assert_eq!(results[1].value, CalcValue::Number { value: 20.0 }); + } + + // === AC: Clone is deep copy === + #[test] + fn test_clone_is_deep_copy() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "x = 10"); + ctx.set_line(1, "x * 2"); + ctx.eval(); + + let mut clone = ctx.clone(); + clone.set_line(0, "x = 99"); + clone.eval(); + + // Original should be unchanged + let orig_results = ctx.eval(); + assert_eq!(orig_results[0].value, CalcValue::Number { value: 10.0 }); + assert_eq!(orig_results[1].value, CalcValue::Number { value: 20.0 }); + + // Clone should have new values + let clone_results = clone.eval(); + assert_eq!(clone_results[0].value, CalcValue::Number { value: 99.0 }); + assert_eq!(clone_results[1].value, CalcValue::Number { value: 198.0 }); + } + + // === AC: Clone performance for undo/redo === + #[test] + fn test_clone_performance_100_lines() { + let mut ctx = SheetContext::new(); + for i in 0..100 { + ctx.set_line(i, &format!("v{} = {}", i, i * 10)); + } + ctx.eval(); + + let start = std::time::Instant::now(); + let _clone = ctx.clone(); + let elapsed = start.elapsed(); + + // Sub-millisecond requirement + assert!( + elapsed.as_millis() < 1, + "Clone took {}ms, expected < 1ms", + elapsed.as_millis() + ); + } + + // === AC: Selective re-evaluation === + #[test] + fn test_selective_re_evaluation() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "a = 10"); + ctx.set_line(1, "b = 20"); + ctx.set_line(2, "c = a + 5"); + ctx.set_line(3, "d = b + 5"); + ctx.eval(); + + // Modify only line 1 (b = 20 -> b = 30) + ctx.set_line(1, "b = 30"); + let results = ctx.eval(); + + // line 0 (a = 10) and line 2 (c = a + 5 = 15) should be unchanged + assert_eq!(results[0].value, CalcValue::Number { value: 10.0 }); + assert_eq!(results[2].value, CalcValue::Number { value: 15.0 }); + // line 1 (b = 30) and line 3 (d = b + 5 = 35) should be updated + assert_eq!(results[1].value, CalcValue::Number { value: 30.0 }); + assert_eq!(results[3].value, CalcValue::Number { value: 35.0 }); + } + + // === AC: Dependency graph tracks correctly === + #[test] + fn test_dependency_graph() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "x = 10"); + ctx.set_line(1, "y = 20"); + ctx.set_line(2, "x + y"); + ctx.set_line(3, "x * 2"); + + let graph = ctx.dependency_graph(); + + // Line 0 and 1 depend on nothing + assert!(graph[&0].is_empty()); + assert!(graph[&1].is_empty()); + // Line 2 depends on lines 0 and 1 + assert!(graph[&2].contains(&0)); + assert!(graph[&2].contains(&1)); + // Line 3 depends on line 0 + assert!(graph[&3].contains(&0)); + assert!(!graph[&3].contains(&1)); + } + + // === AC: Circular dependency detection === + #[test] + fn test_circular_dependency_detection() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "a = b"); + ctx.set_line(1, "b = a"); + + let results = ctx.eval(); + assert_eq!(results.len(), 2); + + // Both lines should be errors + assert_eq!(results[0].result_type(), ResultType::Error); + assert_eq!(results[1].result_type(), ResultType::Error); + + // Check that error messages mention circular dependency + if let CalcValue::Error { message, .. } = &results[0].value { + assert!( + message.contains("ircular"), + "Expected circular dependency error, got: {}", + message + ); + } + } + + // === Additional tests === + + #[test] + fn test_empty_eval() { + let mut ctx = SheetContext::new(); + let results = ctx.eval(); + assert!(results.is_empty()); + } + + #[test] + fn test_parse_error_line() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "10"); + ctx.set_line(1, "???"); + ctx.set_line(2, "20"); + + let results = ctx.eval(); + assert_eq!(results.len(), 3); + assert_eq!(results[0].value, CalcValue::Number { value: 10.0 }); + assert_eq!(results[1].result_type(), ResultType::Error); + assert_eq!(results[2].value, CalcValue::Number { value: 20.0 }); + } + + #[test] + fn test_chained_variables() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "a = 5"); + ctx.set_line(1, "b = a * 2"); + ctx.set_line(2, "c = b + a"); + + let results = ctx.eval(); + assert_eq!(results[0].value, CalcValue::Number { value: 5.0 }); + assert_eq!(results[1].value, CalcValue::Number { value: 10.0 }); + assert_eq!(results[2].value, CalcValue::Number { value: 15.0 }); + } + + #[test] + fn test_remove_line() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "10"); + ctx.set_line(1, "20"); + assert_eq!(ctx.line_count(), 2); + + ctx.remove_line(1); + assert_eq!(ctx.line_count(), 1); + } + + #[test] + fn test_sparse_line_indices() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "x = 10"); + ctx.set_line(5, "x + 5"); + + let results = ctx.eval(); + assert_eq!(results.len(), 2); + assert_eq!(results[0].value, CalcValue::Number { value: 10.0 }); + assert_eq!(results[1].value, CalcValue::Number { value: 15.0 }); + } + + #[test] + fn test_multiple_evals_without_changes() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "x = 10"); + ctx.set_line(1, "x * 2"); + + let results1 = ctx.eval(); + let results2 = ctx.eval(); + + assert_eq!(results1, results2); + } + + #[test] + fn test_no_infinite_loop_on_circular() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "a = b"); + ctx.set_line(1, "b = c"); + ctx.set_line(2, "c = a"); + + // This should complete without hanging + let results = ctx.eval(); + assert_eq!(results.len(), 3); + // All lines in the cycle should be errors + for r in &results { + assert_eq!(r.result_type(), ResultType::Error); + } + } + + #[test] + fn test_independent_lines_not_recomputed() { + let mut ctx = SheetContext::new(); + ctx.set_line(0, "a = 10"); + ctx.set_line(1, "b = 20"); + ctx.set_line(2, "c = a + 5"); + ctx.eval(); + + // Modify only line 1 + ctx.set_line(1, "b = 30"); + + // Check dirty set: only line 1 should be dirty + // Line 0 and line 2 should NOT be dirty since they don't depend on b + let line_indices = ctx.sorted_line_indices(); + let var_to_line = ctx.build_var_to_line_map(&line_indices); + let dirty = ctx.compute_dirty_set(&line_indices, &var_to_line); + + assert!(dirty.contains(&1), "Line 1 should be dirty"); + assert!(!dirty.contains(&0), "Line 0 should NOT be dirty"); + assert!(!dirty.contains(&2), "Line 2 should NOT be dirty (doesn't depend on b)"); + } +} diff --git a/calcpad-engine/src/span.rs b/calcpad-engine/src/span.rs new file mode 100644 index 0000000..990b377 --- /dev/null +++ b/calcpad-engine/src/span.rs @@ -0,0 +1,20 @@ +/// Byte-offset span within source input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span { + pub start: usize, + pub end: usize, +} + +impl Span { + pub fn new(start: usize, end: usize) -> Self { + Self { start, end } + } + + /// Create a span that covers both `self` and `other`. + pub fn merge(self, other: Span) -> Span { + Span { + start: self.start.min(other.start), + end: self.end.max(other.end), + } + } +} diff --git a/calcpad-engine/src/token.rs b/calcpad-engine/src/token.rs new file mode 100644 index 0000000..c9d14b2 --- /dev/null +++ b/calcpad-engine/src/token.rs @@ -0,0 +1,70 @@ +use crate::span::Span; + +/// Arithmetic / symbolic operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Operator { + Add, + Subtract, + Multiply, + Divide, + Power, + Modulo, +} + +/// The kind of a token (payload). +#[derive(Debug, Clone, PartialEq)] +pub enum TokenKind { + /// Numeric literal. + Number(f64), + /// Arithmetic operator. + Op(Operator), + /// Opening parenthesis `(`. + LParen, + /// Closing parenthesis `)`. + RParen, + /// An identifier (variable name, constant like `pi`). + Identifier(String), + /// A unit suffix such as `kg`, `g`, `m`, `lb`. + Unit(String), + /// A currency symbol prefix such as `$`, `€`, `£`. + CurrencySymbol(String), + /// The keyword `in` (for conversions). + In, + /// A percentage literal, e.g. `5%` → stores `5.0`. + Percent(f64), + /// Comparison `>`. + Greater, + /// Comparison `<`. + Less, + /// Comparison `>=`. + GreaterEq, + /// Comparison `<=`. + LessEq, + /// Comparison `==`. + Equal, + /// Comparison `!=`. + NotEqual, + /// Assignment `=`. + Assign, + /// A generic keyword (discount, off, etc.). + Keyword(String), + /// A comment token. + Comment(String), + /// Plain text (non-calculable). + Text(String), + /// End of input sentinel. + Eof, +} + +/// A token produced by the lexer, pairing a kind with its source span. +#[derive(Debug, Clone, PartialEq)] +pub struct Token { + pub kind: TokenKind, + pub span: Span, +} + +impl Token { + pub fn new(kind: TokenKind, span: Span) -> Self { + Self { kind, span } + } +} diff --git a/calcpad-engine/src/types.rs b/calcpad-engine/src/types.rs new file mode 100644 index 0000000..94f36bf --- /dev/null +++ b/calcpad-engine/src/types.rs @@ -0,0 +1,214 @@ +use crate::span::Span; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The result type tag for metadata purposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResultType { + Number, + UnitValue, + CurrencyValue, + DateTime, + TimeDelta, + Boolean, + Error, +} + +impl fmt::Display for ResultType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ResultType::Number => write!(f, "Number"), + ResultType::UnitValue => write!(f, "UnitValue"), + ResultType::CurrencyValue => write!(f, "CurrencyValue"), + ResultType::DateTime => write!(f, "DateTime"), + ResultType::TimeDelta => write!(f, "TimeDelta"), + ResultType::Boolean => write!(f, "Boolean"), + ResultType::Error => write!(f, "Error"), + } + } +} + +/// Serializable span for JSON output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SerializableSpan { + pub start: usize, + pub end: usize, +} + +impl From for SerializableSpan { + fn from(s: Span) -> Self { + SerializableSpan { + start: s.start, + end: s.end, + } + } +} + +/// Metadata attached to every evaluation result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ResultMetadata { + /// The source span of the expression that produced this result. + pub span: SerializableSpan, + /// The type tag of the result. + pub result_type: ResultType, + /// A display-formatted string of the result. + pub display: String, + /// The raw numeric value, if applicable. + pub raw_value: Option, +} + +/// The value payload of a calculation result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum CalcValue { + Number { value: f64 }, + UnitValue { value: f64, unit: String }, + CurrencyValue { amount: f64, currency: String }, + DateTime { date: String }, + TimeDelta { days: i64, description: String }, + Boolean { value: bool }, + Error { message: String, span: SerializableSpan }, +} + +/// A complete calculation result: value + metadata. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CalcResult { + pub value: CalcValue, + pub metadata: ResultMetadata, +} + +impl CalcResult { + pub fn number(val: f64, span: Span) -> Self { + let display = format_number(val); + CalcResult { + value: CalcValue::Number { value: val }, + metadata: ResultMetadata { + span: span.into(), + result_type: ResultType::Number, + display, + raw_value: Some(val), + }, + } + } + + pub fn unit_value(val: f64, unit: &str, span: Span) -> Self { + let display = format!("{} {}", format_number(val), unit); + CalcResult { + value: CalcValue::UnitValue { + value: val, + unit: unit.to_string(), + }, + metadata: ResultMetadata { + span: span.into(), + result_type: ResultType::UnitValue, + display, + raw_value: Some(val), + }, + } + } + + pub fn currency_value(amount: f64, currency: &str, span: Span) -> Self { + let display = format_currency(amount, currency); + CalcResult { + value: CalcValue::CurrencyValue { + amount, + currency: currency.to_string(), + }, + metadata: ResultMetadata { + span: span.into(), + result_type: ResultType::CurrencyValue, + display, + raw_value: Some(amount), + }, + } + } + + pub fn datetime(date: NaiveDate, span: Span) -> Self { + let display = date.format("%Y-%m-%d").to_string(); + CalcResult { + value: CalcValue::DateTime { + date: display.clone(), + }, + metadata: ResultMetadata { + span: span.into(), + result_type: ResultType::DateTime, + display, + raw_value: None, + }, + } + } + + pub fn time_delta(days: i64, description: &str, span: Span) -> Self { + let display = description.to_string(); + CalcResult { + value: CalcValue::TimeDelta { + days, + description: description.to_string(), + }, + metadata: ResultMetadata { + span: span.into(), + result_type: ResultType::TimeDelta, + display, + raw_value: Some(days as f64), + }, + } + } + + pub fn boolean(val: bool, span: Span) -> Self { + let display = val.to_string(); + CalcResult { + value: CalcValue::Boolean { value: val }, + metadata: ResultMetadata { + span: span.into(), + result_type: ResultType::Boolean, + display, + raw_value: None, + }, + } + } + + pub fn error(message: &str, span: Span) -> Self { + CalcResult { + value: CalcValue::Error { + message: message.to_string(), + span: span.into(), + }, + metadata: ResultMetadata { + span: span.into(), + result_type: ResultType::Error, + display: format!("Error: {}", message), + raw_value: None, + }, + } + } + + pub fn result_type(&self) -> ResultType { + self.metadata.result_type + } +} + +fn format_number(val: f64) -> String { + if val == val.floor() && val.abs() < 1e15 { + format!("{}", val as i64) + } else { + format!("{}", val) + } +} + +fn format_currency(amount: f64, currency: &str) -> String { + let symbol = match currency { + "USD" => "$", + "EUR" => "€", + "GBP" => "£", + "JPY" => "¥", + "BRL" => "R$", + _ => "", + }; + + if symbol.is_empty() { + format!("{:.2} {}", amount, currency) + } else { + format!("{}{:.2}", symbol, amount) + } +} diff --git a/calcpad-engine/tests/ffi_tests.rs b/calcpad-engine/tests/ffi_tests.rs new file mode 100644 index 0000000..860a032 --- /dev/null +++ b/calcpad-engine/tests/ffi_tests.rs @@ -0,0 +1,445 @@ +use calcpad_engine::context::EvalContext; +use calcpad_engine::pipeline::{eval_line, eval_sheet}; +use calcpad_engine::types::ResultType; +use calcpad_engine::ffi::{FfiResponse, FfiSheetResponse}; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +// Re-declare FFI functions for testing +extern "C" { + fn calcpad_eval_line(input: *const c_char) -> *mut c_char; + fn calcpad_eval_sheet(lines: *const *const c_char, count: i32) -> *mut c_char; + fn calcpad_free_result(ptr: *mut c_char); +} + +// ===== Pipeline tests ===== + +#[test] +fn pipeline_eval_simple_addition() { + let mut ctx = EvalContext::new(); + let result = eval_line("2 + 3", &mut ctx); + assert_eq!(result.result_type(), ResultType::Number); + assert_eq!(result.metadata.display, "5"); + assert_eq!(result.metadata.raw_value, Some(5.0)); +} + +#[test] +fn pipeline_eval_multiplication() { + let mut ctx = EvalContext::new(); + let result = eval_line("6 * 7", &mut ctx); + assert_eq!(result.metadata.display, "42"); +} + +#[test] +fn pipeline_eval_division() { + let mut ctx = EvalContext::new(); + let result = eval_line("10 / 4", &mut ctx); + assert_eq!(result.metadata.raw_value, Some(2.5)); +} + +#[test] +fn pipeline_eval_exponentiation() { + let mut ctx = EvalContext::new(); + let result = eval_line("2 ^ 10", &mut ctx); + assert_eq!(result.metadata.display, "1024"); +} + +#[test] +fn pipeline_eval_parentheses() { + let mut ctx = EvalContext::new(); + let result = eval_line("(2 + 3) * 4", &mut ctx); + assert_eq!(result.metadata.display, "20"); +} + +#[test] +fn pipeline_eval_unary_neg() { + let mut ctx = EvalContext::new(); + let result = eval_line("-5 + 3", &mut ctx); + assert_eq!(result.metadata.display, "-2"); +} + +#[test] +fn pipeline_eval_percentage() { + let mut ctx = EvalContext::new(); + let result = eval_line("100 - 20%", &mut ctx); + assert_eq!(result.metadata.display, "80"); +} + +#[test] +fn pipeline_eval_unit_number() { + let mut ctx = EvalContext::new(); + let result = eval_line("5kg", &mut ctx); + assert_eq!(result.result_type(), ResultType::UnitValue); + assert_eq!(result.metadata.display, "5 kg"); +} + +#[test] +fn pipeline_eval_currency() { + let mut ctx = EvalContext::new(); + let result = eval_line("$20", &mut ctx); + assert_eq!(result.result_type(), ResultType::CurrencyValue); + assert!(result.metadata.display.contains("20")); +} + +#[test] +fn pipeline_eval_comparison() { + let mut ctx = EvalContext::new(); + let result = eval_line("5 > 3", &mut ctx); + assert_eq!(result.result_type(), ResultType::Boolean); + assert_eq!(result.metadata.display, "true"); +} + +#[test] +fn pipeline_eval_division_by_zero() { + let mut ctx = EvalContext::new(); + let result = eval_line("10 / 0", &mut ctx); + assert_eq!(result.result_type(), ResultType::Error); +} + +#[test] +fn pipeline_eval_empty_input() { + let mut ctx = EvalContext::new(); + let result = eval_line("", &mut ctx); + assert_eq!(result.result_type(), ResultType::Error); +} + +#[test] +fn pipeline_eval_comment_only() { + let mut ctx = EvalContext::new(); + let result = eval_line("// this is a comment", &mut ctx); + assert_eq!(result.result_type(), ResultType::Error); +} + +// ===== Variable assignment tests (eval_sheet) ===== + +#[test] +fn pipeline_eval_sheet_basic() { + let mut ctx = EvalContext::new(); + let results = eval_sheet(&["2 + 3", "10 * 2"], &mut ctx); + assert_eq!(results.len(), 2); + assert_eq!(results[0].metadata.display, "5"); + assert_eq!(results[1].metadata.display, "20"); +} + +#[test] +fn pipeline_eval_sheet_variable_assignment() { + let mut ctx = EvalContext::new(); + let results = eval_sheet(&["x = 5", "x + 3"], &mut ctx); + assert_eq!(results.len(), 2); + assert_eq!(results[0].metadata.display, "5"); + assert_eq!(results[1].metadata.display, "8"); +} + +#[test] +fn pipeline_eval_sheet_multiple_variables() { + let mut ctx = EvalContext::new(); + let results = eval_sheet(&["a = 10", "b = 20", "a + b"], &mut ctx); + assert_eq!(results.len(), 3); + assert_eq!(results[0].metadata.display, "10"); + assert_eq!(results[1].metadata.display, "20"); + assert_eq!(results[2].metadata.display, "30"); +} + +#[test] +fn pipeline_eval_sheet_variable_reassignment() { + let mut ctx = EvalContext::new(); + let results = eval_sheet(&["x = 5", "x = 10", "x"], &mut ctx); + assert_eq!(results.len(), 3); + assert_eq!(results[2].metadata.display, "10"); +} + +#[test] +fn pipeline_eval_sheet_undefined_variable() { + let mut ctx = EvalContext::new(); + let results = eval_sheet(&["y + 3"], &mut ctx); + assert_eq!(results[0].result_type(), ResultType::Error); +} + +// ===== FFI function tests ===== + +#[test] +fn ffi_eval_line_basic() { + let input = CString::new("2 + 3").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + assert!(!result_ptr.is_null()); + + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + let response: FfiResponse = serde_json::from_str(result_str).unwrap(); + + assert_eq!(response.schema_version, "1.0"); + assert_eq!(response.result.result_type(), ResultType::Number); + assert_eq!(response.result.metadata.display, "5"); + assert_eq!(response.result.metadata.raw_value, Some(5.0)); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn ffi_eval_line_complex_expression() { + let input = CString::new("(10 + 5) * 2").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + assert!(!result_ptr.is_null()); + + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + let response: FfiResponse = serde_json::from_str(result_str).unwrap(); + + assert_eq!(response.result.metadata.display, "30"); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn ffi_eval_line_null_input() { + unsafe { + let result_ptr = calcpad_eval_line(ptr::null()); + assert!(!result_ptr.is_null()); + + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + let response: FfiResponse = serde_json::from_str(result_str).unwrap(); + + assert_eq!(response.result.result_type(), ResultType::Error); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn ffi_eval_sheet_basic() { + let line1 = CString::new("2 + 3").unwrap(); + let line2 = CString::new("10 * 2").unwrap(); + let lines: Vec<*const c_char> = vec![line1.as_ptr(), line2.as_ptr()]; + + unsafe { + let result_ptr = calcpad_eval_sheet(lines.as_ptr(), 2); + assert!(!result_ptr.is_null()); + + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + let response: FfiSheetResponse = serde_json::from_str(result_str).unwrap(); + + assert_eq!(response.schema_version, "1.0"); + assert_eq!(response.results.len(), 2); + assert_eq!(response.results[0].metadata.display, "5"); + assert_eq!(response.results[1].metadata.display, "20"); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn ffi_eval_sheet_with_variables() { + let line1 = CString::new("x = 5").unwrap(); + let line2 = CString::new("x + 10").unwrap(); + let lines: Vec<*const c_char> = vec![line1.as_ptr(), line2.as_ptr()]; + + unsafe { + let result_ptr = calcpad_eval_sheet(lines.as_ptr(), 2); + assert!(!result_ptr.is_null()); + + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + let response: FfiSheetResponse = serde_json::from_str(result_str).unwrap(); + + assert_eq!(response.results.len(), 2); + assert_eq!(response.results[0].metadata.display, "5"); + assert_eq!(response.results[1].metadata.display, "15"); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn ffi_eval_sheet_null_lines() { + unsafe { + let result_ptr = calcpad_eval_sheet(ptr::null(), 0); + assert!(!result_ptr.is_null()); + + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + let response: FfiResponse = serde_json::from_str(result_str).unwrap(); + + assert_eq!(response.result.result_type(), ResultType::Error); + + calcpad_free_result(result_ptr); + } +} + +// ===== Panic safety tests ===== + +#[test] +fn ffi_panic_safety_eval_line() { + // The catch_unwind in eval_line should handle any internal panics. + // Test with various edge cases that might trigger unexpected behavior. + let input = CString::new("2 + 3").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + assert!(!result_ptr.is_null()); + calcpad_free_result(result_ptr); + } +} + +#[test] +fn ffi_panic_safety_null_input() { + // Null input should not crash — should return error JSON + unsafe { + let result_ptr = calcpad_eval_line(ptr::null()); + assert!(!result_ptr.is_null()); + + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + assert!(result_str.contains("error") || result_str.contains("Error")); + + calcpad_free_result(result_ptr); + } +} + +// ===== Memory management tests ===== + +#[test] +fn ffi_free_result_null() { + // Freeing null should be a safe no-op + unsafe { + calcpad_free_result(ptr::null_mut()); + } + // If we get here without crashing, the test passes +} + +#[test] +fn ffi_free_result_valid() { + let input = CString::new("42").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + assert!(!result_ptr.is_null()); + // Free the result — should not crash + calcpad_free_result(result_ptr); + } + // If we get here without crashing, the test passes +} + +#[test] +fn ffi_multiple_allocations() { + // Allocate and free multiple results to check for memory leaks + for _ in 0..100 { + let input = CString::new("1 + 1").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + assert!(!result_ptr.is_null()); + calcpad_free_result(result_ptr); + } + } +} + +// ===== JSON schema versioning tests ===== + +#[test] +fn json_schema_version_present() { + let input = CString::new("42").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + + let json: serde_json::Value = serde_json::from_str(result_str).unwrap(); + assert_eq!(json["schema_version"], "1.0"); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn json_schema_contains_required_fields() { + let input = CString::new("42").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + + let json: serde_json::Value = serde_json::from_str(result_str).unwrap(); + + // Check schema_version + assert!(json["schema_version"].is_string()); + + // Check result structure + assert!(json["result"]["value"].is_object()); + assert!(json["result"]["metadata"].is_object()); + + // Check metadata fields + let metadata = &json["result"]["metadata"]; + assert!(metadata["result_type"].is_string()); + assert!(metadata["display"].is_string()); + assert!(metadata["span"].is_object()); + assert!(metadata["span"]["start"].is_number()); + assert!(metadata["span"]["end"].is_number()); + + // Check raw_value is present (can be number or null) + assert!( + metadata["raw_value"].is_number() || metadata["raw_value"].is_null(), + "raw_value should be a number or null" + ); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn json_schema_result_type_field() { + let input = CString::new("42").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + + let json: serde_json::Value = serde_json::from_str(result_str).unwrap(); + assert_eq!(json["result"]["metadata"]["result_type"], "Number"); + assert_eq!(json["result"]["value"]["kind"], "Number"); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn json_schema_error_result() { + let input = CString::new("10 / 0").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + + let json: serde_json::Value = serde_json::from_str(result_str).unwrap(); + assert_eq!(json["result"]["metadata"]["result_type"], "Error"); + assert_eq!(json["result"]["value"]["kind"], "Error"); + assert!(json["result"]["value"]["message"].is_string()); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn json_schema_sheet_version() { + let line1 = CString::new("1 + 1").unwrap(); + let lines: Vec<*const c_char> = vec![line1.as_ptr()]; + + unsafe { + let result_ptr = calcpad_eval_sheet(lines.as_ptr(), 1); + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + + let json: serde_json::Value = serde_json::from_str(result_str).unwrap(); + assert_eq!(json["schema_version"], "1.0"); + assert!(json["results"].is_array()); + assert_eq!(json["results"].as_array().unwrap().len(), 1); + + calcpad_free_result(result_ptr); + } +} + +#[test] +fn json_display_value_and_raw_value() { + let input = CString::new("2.5 * 4").unwrap(); + unsafe { + let result_ptr = calcpad_eval_line(input.as_ptr()); + let result_str = CStr::from_ptr(result_ptr).to_str().unwrap(); + + let json: serde_json::Value = serde_json::from_str(result_str).unwrap(); + assert_eq!(json["result"]["metadata"]["display"], "10"); + assert_eq!(json["result"]["metadata"]["raw_value"], 10.0); + + calcpad_free_result(result_ptr); + } +} diff --git a/calcpad-wasm/Cargo.toml b/calcpad-wasm/Cargo.toml new file mode 100644 index 0000000..bf5ac3e --- /dev/null +++ b/calcpad-wasm/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "calcpad-wasm" +version = "0.1.0" +edition = "2021" +description = "CalcPad calculation engine compiled to WebAssembly" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +calcpad-engine = { path = "../calcpad-engine" } +wasm-bindgen = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde-wasm-bindgen = "0.6" +js-sys = "0.3" +chrono = "0.4" + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[profile.release] +opt-level = "z" +lto = true diff --git a/calcpad-wasm/src/lib.rs b/calcpad-wasm/src/lib.rs new file mode 100644 index 0000000..2dd5efe --- /dev/null +++ b/calcpad-wasm/src/lib.rs @@ -0,0 +1,170 @@ +use calcpad_engine::{CalcResult, EvalContext, ResultType}; +use serde::Serialize; +use wasm_bindgen::prelude::*; + +/// JSON-serializable result for WASM consumers. +/// +/// Fields match the contract expected by the web frontend: +/// - `type` : "number" | "unitValue" | "currencyValue" | "dateTime" +/// | "timeDelta" | "boolean" | "error" +/// - `display` : human-readable formatted string +/// - `rawValue` : numeric value when applicable, otherwise null +/// - `error` : error message when type == "error", otherwise null +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JsResult { + #[serde(rename = "type")] + pub result_type: String, + pub display: String, + pub raw_value: Option, + pub error: Option, +} + +impl From<&CalcResult> for JsResult { + fn from(r: &CalcResult) -> Self { + let result_type = match r.metadata.result_type { + ResultType::Number => "number", + ResultType::UnitValue => "unitValue", + ResultType::CurrencyValue => "currencyValue", + ResultType::DateTime => "dateTime", + ResultType::TimeDelta => "timeDelta", + ResultType::Boolean => "boolean", + ResultType::Error => "error", + } + .to_string(); + + let error = if r.metadata.result_type == ResultType::Error { + // Strip the "Error: " prefix that the engine prepends to display. + let msg = r + .metadata + .display + .strip_prefix("Error: ") + .unwrap_or(&r.metadata.display); + Some(msg.to_string()) + } else { + None + }; + + JsResult { + result_type, + display: r.metadata.display.clone(), + raw_value: r.metadata.raw_value, + error, + } + } +} + +/// Build an `EvalContext`, pulling the current date from JS when running +/// inside a WASM runtime. +fn create_default_context() -> EvalContext { + #[cfg(target_arch = "wasm32")] + { + let js_date = js_sys::Date::new_0(); + let year = js_date.get_full_year() as i32; + let month = js_date.get_month() as u32 + 1; // JS months are 0-indexed + let day = js_date.get_date() as u32; + if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) { + EvalContext::with_date(date) + } else { + EvalContext::default() + } + } + #[cfg(not(target_arch = "wasm32"))] + { + EvalContext::default() + } +} + +// --------------------------------------------------------------------------- +// Public WASM API +// --------------------------------------------------------------------------- + +/// Evaluate a single line of CalcPad input. +/// +/// Returns a JS object: `{ type, display, rawValue, error }`. +#[wasm_bindgen(js_name = "evalLine")] +pub fn eval_line(input: &str) -> JsValue { + let mut ctx = create_default_context(); + let result = calcpad_engine::eval_line(input, &mut ctx); + let js_result = JsResult::from(&result); + serde_wasm_bindgen::to_value(&js_result).unwrap_or(JsValue::NULL) +} + +/// Evaluate a sheet (array of lines) with shared variable context. +/// +/// Accepts a JS array of strings and returns a JS array of result objects. +#[wasm_bindgen(js_name = "evalSheet")] +pub fn eval_sheet(lines: JsValue) -> JsValue { + let lines: Vec = serde_wasm_bindgen::from_value(lines).unwrap_or_default(); + let mut ctx = create_default_context(); + + let results: Vec = lines + .iter() + .map(|line| { + let result = calcpad_engine::eval_line(line, &mut ctx); + JsResult::from(&result) + }) + .collect(); + + serde_wasm_bindgen::to_value(&results).unwrap_or(JsValue::NULL) +} + +// --------------------------------------------------------------------------- +// Native tests (run with `cargo test`, not wasm-bindgen-test) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> EvalContext { + EvalContext::with_date(chrono::NaiveDate::from_ymd_opt(2026, 3, 16).unwrap()) + } + + #[test] + fn js_result_from_number() { + let mut ctx = ctx(); + let r = calcpad_engine::eval_line("2 + 3", &mut ctx); + let js = JsResult::from(&r); + assert_eq!(js.result_type, "number"); + assert_eq!(js.display, "5"); + assert_eq!(js.raw_value, Some(5.0)); + assert!(js.error.is_none()); + } + + #[test] + fn js_result_from_error() { + let mut ctx = ctx(); + let r = calcpad_engine::eval_line("1 / 0", &mut ctx); + let js = JsResult::from(&r); + assert_eq!(js.result_type, "error"); + assert!(js.error.is_some()); + } + + #[test] + fn sheet_context_flows() { + let mut ctx = ctx(); + + let r1 = calcpad_engine::eval_line("x = 10", &mut ctx); + assert_eq!(JsResult::from(&r1).display, "10"); + + let r2 = calcpad_engine::eval_line("y = x + 5", &mut ctx); + assert_eq!(JsResult::from(&r2).display, "15"); + + let r3 = calcpad_engine::eval_line("y * 2", &mut ctx); + assert_eq!(JsResult::from(&r3).display, "30"); + } + + #[test] + fn float_precision() { + // NOTE: Engine still uses f64 internally. Once number.rs (dashu) is wired + // into the interpreter, this will produce exact "0.3". For now, accept f64 result. + let mut ctx = ctx(); + let r = calcpad_engine::eval_line("0.1 + 0.2", &mut ctx); + let display = JsResult::from(&r).display; + assert!( + display == "0.3" || display == "0.30000000000000004", + "unexpected: {display}" + ); + } +} diff --git a/run-pipeline.sh b/run-pipeline.sh index 082c048..e752cc6 100755 --- a/run-pipeline.sh +++ b/run-pipeline.sh @@ -336,7 +336,7 @@ phase_dev() { local dev_log="${LOG_DIR}/${TIMESTAMP}-dev-${story_name}.log" run_claude \ "Developing: $story_name" \ - "/bmad-dev-story $story_path" \ + "You are running in a fully autonomous pipeline — do NOT ask questions, do NOT wait for input. If the story file is missing task breakdowns, create them yourself and proceed immediately. Always choose option 1 (do it yourself). /bmad-dev-story $story_path" \ "$dev_log" \ "$work_dir" || return 1 @@ -478,8 +478,26 @@ process_story_worktree() { fi log STEP "Creating worktree: $story_name → $branch" - git -C "$PROJECT_ROOT" branch "$branch" main 2>/dev/null || true - git -C "$PROJECT_ROOT" worktree add "$wt_path" "$branch" 2>/dev/null + # Reset branch to main if it already exists, or create fresh + if git -C "$PROJECT_ROOT" rev-parse --verify "$branch" &>/dev/null; then + git -C "$PROJECT_ROOT" branch -f "$branch" main 2>/dev/null || true + log INFO "Reset existing branch: $branch" + else + if ! git -C "$PROJECT_ROOT" branch "$branch" main 2>&1; then + log ERROR "[parallel] Failed to create branch: $branch" + echo "1" > "${RESULTS_DIR}/${story_name}.result" + return 1 + fi + fi + # Remove stale worktree if path exists + if [ -d "$wt_path" ]; then + git -C "$PROJECT_ROOT" worktree remove --force "$wt_path" 2>/dev/null || rm -rf "$wt_path" + fi + if ! git -C "$PROJECT_ROOT" worktree add "$wt_path" "$branch" 2>&1; then + log ERROR "[parallel] Failed to create worktree: $wt_path" + echo "1" > "${RESULTS_DIR}/${story_name}.result" + return 1 + fi # Symlink .claude/ so skills are available in worktree if [ -d "${PROJECT_ROOT}/.claude" ] && [ ! -e "${wt_path}/.claude" ]; then diff --git a/src/lexer.rs b/src/lexer.rs deleted file mode 100644 index 6ed06b0..0000000 --- a/src/lexer.rs +++ /dev/null @@ -1,733 +0,0 @@ -use crate::token::{Operator, Span, Token, TokenKind}; - -/// A line-oriented lexer for CalcPad expressions. -/// -/// The lexer scans a single line of input and produces a vector of [`Token`]s, -/// each annotated with its byte [`Span`] within the input. -pub struct Lexer<'a> { - /// The input string being scanned. - input: &'a str, - /// The input as a byte slice for fast access. - bytes: &'a [u8], - /// Current byte offset into the input. - pos: usize, - /// A pending token to emit on the next call to `scan_token`. - pending: Option, -} - -impl<'a> Lexer<'a> { - /// Create a new lexer for the given input line. - pub fn new(input: &'a str) -> Self { - Self { - input, - bytes: input.as_bytes(), - pos: 0, - pending: None, - } - } - - /// Tokenize the entire input line into a token stream. - /// - /// If the input contains no calculable tokens, the entire line is returned - /// as a single [`TokenKind::Text`] token. - pub fn tokenize(&mut self) -> Vec { - // Empty or whitespace-only input produces no tokens. - if self.input.trim().is_empty() { - return Vec::new(); - } - - // First check for comment at the start (possibly after whitespace). - let trimmed = self.input.trim_start(); - let trimmed_start = self.input.len() - trimmed.len(); - if trimmed.starts_with("//") { - let comment_text = self.input[trimmed_start + 2..].to_string(); - return vec![Token::new( - TokenKind::Comment(comment_text), - Span::new(trimmed_start, self.input.len()), - )]; - } - - let mut tokens = Vec::new(); - loop { - // Emit any pending token first (e.g., Unit after Number). - if let Some(tok) = self.pending.take() { - tokens.push(tok); - continue; - } - if self.pos >= self.bytes.len() { - break; - } - self.skip_whitespace(); - if self.pos >= self.bytes.len() { - break; - } - if let Some(tok) = self.scan_token() { - tokens.push(tok); - } - } - - // If no calculable tokens were produced, return the whole line as Text. - if tokens.is_empty() && !self.input.trim().is_empty() { - return vec![Token::new( - TokenKind::Text(self.input.to_string()), - Span::new(0, self.input.len()), - )]; - } - - // Check if all tokens are identifiers/keywords with no numbers or operators — - // that's plain text. - let has_calculable = tokens.iter().any(|t| { - matches!( - t.kind, - TokenKind::Number(_) - | TokenKind::Operator(_) - | TokenKind::Assign - | TokenKind::Percent(_) - | TokenKind::CurrencySymbol(_) - | TokenKind::Unit(_) - | TokenKind::LParen - | TokenKind::RParen - ) - }); - if !has_calculable { - return vec![Token::new( - TokenKind::Text(self.input.to_string()), - Span::new(0, self.input.len()), - )]; - } - - tokens - } - - /// Skip ASCII whitespace characters. - fn skip_whitespace(&mut self) { - while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() { - self.pos += 1; - } - } - - /// Peek at the current byte without advancing. - fn peek(&self) -> Option { - self.bytes.get(self.pos).copied() - } - - /// Peek at the byte at offset `n` from current position. - fn peek_ahead(&self, n: usize) -> Option { - self.bytes.get(self.pos + n).copied() - } - - /// Advance by one byte and return it. - fn advance(&mut self) -> Option { - if self.pos < self.bytes.len() { - let b = self.bytes[self.pos]; - self.pos += 1; - Some(b) - } else { - None - } - } - - /// Check if the remaining input starting at `pos` matches the given string - /// (case-insensitive) followed by a word boundary. - fn matches_word(&self, word: &str) -> bool { - let remaining = &self.input[self.pos..]; - if remaining.len() < word.len() { - return false; - } - if !remaining[..word.len()].eq_ignore_ascii_case(word) { - return false; - } - // Must be at end of input or followed by a non-alphanumeric character. - if remaining.len() == word.len() { - return true; - } - let next = remaining.as_bytes()[word.len()]; - !next.is_ascii_alphanumeric() && next != b'_' - } - - /// Scan a single token from the current position. - fn scan_token(&mut self) -> Option { - let b = self.peek()?; - - // Comment: // ... - if b == b'/' && self.peek_ahead(1) == Some(b'/') { - return Some(self.scan_comment()); - } - - // Currency symbols (multi-byte UTF-8 or ASCII $) - if let Some(tok) = self.try_scan_currency() { - return Some(tok); - } - - // Numbers - if b.is_ascii_digit() || (b == b'.' && self.peek_ahead(1).is_some_and(|c| c.is_ascii_digit())) { - return Some(self.scan_number()); - } - - // Operators and punctuation - match b { - b'+' => return Some(self.single_char_token(TokenKind::Operator(Operator::Plus))), - b'-' => return Some(self.single_char_token(TokenKind::Operator(Operator::Minus))), - b'*' => return Some(self.single_char_token(TokenKind::Operator(Operator::Star))), - b'/' => return Some(self.single_char_token(TokenKind::Operator(Operator::Slash))), - b'^' => return Some(self.single_char_token(TokenKind::Operator(Operator::Caret))), - b'(' => return Some(self.single_char_token(TokenKind::LParen)), - b')' => return Some(self.single_char_token(TokenKind::RParen)), - b'=' => return Some(self.single_char_token(TokenKind::Assign)), - _ => {} - } - - // Alphabetic — could be natural language operator, keyword, unit, or identifier - if b.is_ascii_alphabetic() || b == b'_' { - return Some(self.scan_word()); - } - - // Percent sign alone (rare — usually after a number, but handle it) - if b == b'%' { - return Some(self.single_char_token(TokenKind::Operator(Operator::Percent))); - } - - // Unknown character — skip it - self.advance(); - None - } - - /// Consume a single byte and produce a token. - fn single_char_token(&mut self, kind: TokenKind) -> Token { - let start = self.pos; - self.advance(); - Token::new(kind, Span::new(start, self.pos)) - } - - /// Scan a `//` comment to end of line. - fn scan_comment(&mut self) -> Token { - let start = self.pos; - self.pos += 2; // skip // - let text = self.input[self.pos..].to_string(); - self.pos = self.bytes.len(); - Token::new(TokenKind::Comment(text), Span::new(start, self.pos)) - } - - /// Try to scan a currency symbol. Returns `None` if current position is not a currency symbol. - fn try_scan_currency(&mut self) -> Option { - let start = self.pos; - let remaining = &self.input[self.pos..]; - - // Multi-char: R$ - if remaining.starts_with("R$") { - self.pos += 2; - return Some(Token::new( - TokenKind::CurrencySymbol("R$".to_string()), - Span::new(start, self.pos), - )); - } - - // Single ASCII: $ - if remaining.starts_with('$') { - self.pos += 1; - return Some(Token::new( - TokenKind::CurrencySymbol("$".to_string()), - Span::new(start, self.pos), - )); - } - - // Multi-byte UTF-8 currency symbols: €, £, ¥ - for sym in &["€", "£", "¥"] { - if remaining.starts_with(sym) { - self.pos += sym.len(); - return Some(Token::new( - TokenKind::CurrencySymbol(sym.to_string()), - Span::new(start, self.pos), - )); - } - } - - None - } - - /// Scan a numeric literal, including decimals, scientific notation, SI suffixes, - /// and percent suffix. - fn scan_number(&mut self) -> Token { - let start = self.pos; - - // Consume digits - self.consume_digits(); - - // Decimal point - if self.peek() == Some(b'.') && self.peek_ahead(1).is_some_and(|c| c.is_ascii_digit()) { - self.advance(); // consume '.' - self.consume_digits(); - } - - // Scientific notation: e/E followed by optional +/- and digits - if let Some(e) = self.peek() { - if e == b'e' || e == b'E' { - let next = self.peek_ahead(1); - let has_digits = match next { - Some(b'+') | Some(b'-') => self.peek_ahead(2).is_some_and(|c| c.is_ascii_digit()), - Some(c) => c.is_ascii_digit(), - None => false, - }; - if has_digits { - self.advance(); // consume e/E - if let Some(b'+') | Some(b'-') = self.peek() { - self.advance(); // consume sign - } - self.consume_digits(); - } - } - } - - let number_end = self.pos; - let raw_number: f64 = self.input[start..number_end].parse().unwrap_or(0.0); - - // Check for percent suffix - if self.peek() == Some(b'%') { - self.advance(); // consume % - return Token::new( - TokenKind::Percent(raw_number), - Span::new(start, self.pos), - ); - } - - // Check for SI scale suffix (k, M, B, T) — but only if NOT followed by more letters - // (to avoid matching "kg" as SI suffix "k" + "g") - if let Some(suffix) = self.peek() { - let scale = match suffix { - b'k' if !self.is_unit_suffix_start() => Some(1_000.0), - b'M' if !self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) => Some(1_000_000.0), - b'B' if !self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) => Some(1_000_000_000.0), - b'T' if !self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) => Some(1_000_000_000_000.0), - _ => None, - }; - if let Some(multiplier) = scale { - self.advance(); // consume suffix - return Token::new( - TokenKind::Number(raw_number * multiplier), - Span::new(start, self.pos), - ); - } - } - - // Check for unit suffix directly after number (no space) - if let Some(b) = self.peek() { - if b.is_ascii_alphabetic() { - let unit_start = self.pos; - self.consume_alpha(); - let unit_str = self.input[unit_start..self.pos].to_string(); - // Store the Unit token as pending — it will be emitted on the next scan_token call. - self.pending = Some(Token::new( - TokenKind::Unit(unit_str), - Span::new(unit_start, self.pos), - )); - return Token::new( - TokenKind::Number(raw_number), - Span::new(start, number_end), - ); - } - } - - Token::new( - TokenKind::Number(raw_number), - Span::new(start, number_end), - ) - } - - /// Check if current position starts what looks like a multi-letter unit suffix - /// (e.g., "kg" after a number — 'k' followed by more alpha). - fn is_unit_suffix_start(&self) -> bool { - // 'k' at current pos — check if next char is also alphabetic - self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) - } - - /// Consume a run of ASCII digits. - fn consume_digits(&mut self) { - while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() { - self.pos += 1; - } - } - - /// Consume a run of ASCII alphabetic characters. - fn consume_alpha(&mut self) { - while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_alphabetic() { - self.pos += 1; - } - } - - /// Scan an alphabetic word: could be a natural language operator, keyword, unit, or identifier. - fn scan_word(&mut self) -> Token { - // Check for "divided by" two-word operator - if self.matches_word("divided") { - let start = self.pos; - self.pos += "divided".len(); - self.skip_whitespace(); - if self.matches_word("by") { - self.pos += "by".len(); - return Token::new( - TokenKind::Operator(Operator::Slash), - Span::new(start, self.pos), - ); - } - // Not "divided by" — treat "divided" as identifier - self.pos = start + "divided".len(); - return Token::new( - TokenKind::Identifier("divided".to_string()), - Span::new(start, self.pos), - ); - } - - // Natural language operators - let nl_ops: &[(&str, Operator)] = &[ - ("plus", Operator::Plus), - ("minus", Operator::Minus), - ("times", Operator::Star), - ]; - for &(word, ref op) in nl_ops { - if self.matches_word(word) { - let start = self.pos; - self.pos += word.len(); - return Token::new( - TokenKind::Operator(*op), - Span::new(start, self.pos), - ); - } - } - - // Keywords - let keywords = ["in", "to", "as", "of", "discount", "off", "euro", "usd", "gbp"]; - for kw in &keywords { - if self.matches_word(kw) { - let start = self.pos; - self.pos += kw.len(); - return Token::new( - TokenKind::Keyword(kw.to_string()), - Span::new(start, self.pos), - ); - } - } - - // Generic identifier (variable names, function names, or unit suffixes after space) - let start = self.pos; - while self.pos < self.bytes.len() - && (self.bytes[self.pos].is_ascii_alphanumeric() || self.bytes[self.pos] == b'_') - { - self.pos += 1; - } - let word = self.input[start..self.pos].to_string(); - - // If this word immediately follows a number token, it's a unit - // But we handle that in scan_number — here it's just an identifier - Token::new( - TokenKind::Identifier(word), - Span::new(start, self.pos), - ) - } -} - -/// Convenience function to tokenize an input line. -pub fn tokenize(input: &str) -> Vec { - Lexer::new(input).tokenize() -} - -#[cfg(test)] -mod tests { - use super::*; - - // ===== Task 2: Core infrastructure tests ===== - - #[test] - fn empty_input() { - let tokens = tokenize(""); - assert!(tokens.is_empty()); - } - - #[test] - fn whitespace_only() { - let tokens = tokenize(" "); - assert!(tokens.is_empty()); - } - - // ===== Task 3: Number tests ===== - - #[test] - fn integer() { - let tokens = tokenize("42"); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].kind, TokenKind::Number(42.0)); - assert_eq!(tokens[0].span, Span::new(0, 2)); - } - - #[test] - fn decimal() { - let tokens = tokenize("3.14"); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].kind, TokenKind::Number(3.14)); - assert_eq!(tokens[0].span, Span::new(0, 4)); - } - - #[test] - fn scientific_notation() { - let tokens = tokenize("6.022e23"); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].kind, TokenKind::Number(6.022e23)); - } - - #[test] - fn scientific_notation_with_sign() { - let tokens = tokenize("1.5e-3"); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].kind, TokenKind::Number(1.5e-3)); - } - - #[test] - fn si_suffix_k() { - let tokens = tokenize("5k"); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].kind, TokenKind::Number(5000.0)); - } - - #[test] - fn si_suffix_m() { - let tokens = tokenize("2.5M"); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].kind, TokenKind::Number(2_500_000.0)); - } - - #[test] - fn si_suffix_b() { - let tokens = tokenize("1B"); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].kind, TokenKind::Number(1_000_000_000.0)); - } - - // ===== Task 4: Operator tests ===== - - #[test] - fn symbolic_operators() { - let input = "+ - * / ^ %"; - let tokens = tokenize(input); - assert_eq!(tokens.len(), 6); - assert_eq!(tokens[0].kind, TokenKind::Operator(Operator::Plus)); - assert_eq!(tokens[1].kind, TokenKind::Operator(Operator::Minus)); - assert_eq!(tokens[2].kind, TokenKind::Operator(Operator::Star)); - assert_eq!(tokens[3].kind, TokenKind::Operator(Operator::Slash)); - assert_eq!(tokens[4].kind, TokenKind::Operator(Operator::Caret)); - assert_eq!(tokens[5].kind, TokenKind::Operator(Operator::Percent)); - } - - #[test] - fn natural_language_plus() { - let tokens = tokenize("5 plus 3"); - assert_eq!(tokens.len(), 3); - assert_eq!(tokens[0].kind, TokenKind::Number(5.0)); - assert_eq!(tokens[1].kind, TokenKind::Operator(Operator::Plus)); - assert_eq!(tokens[2].kind, TokenKind::Number(3.0)); - } - - #[test] - fn natural_language_minus() { - let tokens = tokenize("10 minus 4"); - assert_eq!(tokens.len(), 3); - assert_eq!(tokens[1].kind, TokenKind::Operator(Operator::Minus)); - } - - #[test] - fn natural_language_times() { - let tokens = tokenize("6 times 7"); - assert_eq!(tokens.len(), 3); - assert_eq!(tokens[1].kind, TokenKind::Operator(Operator::Star)); - } - - #[test] - fn natural_language_divided_by() { - let tokens = tokenize("20 divided by 4"); - assert_eq!(tokens.len(), 3); - assert_eq!(tokens[0].kind, TokenKind::Number(20.0)); - assert_eq!(tokens[1].kind, TokenKind::Operator(Operator::Slash)); - assert_eq!(tokens[2].kind, TokenKind::Number(4.0)); - } - - #[test] - fn parentheses() { - let tokens = tokenize("(1 + 2)"); - assert_eq!(tokens.len(), 5); - assert_eq!(tokens[0].kind, TokenKind::LParen); - assert_eq!(tokens[4].kind, TokenKind::RParen); - } - - // ===== Task 5: Identifiers, assignments, currency, units ===== - - #[test] - fn variable_assignment() { - let tokens = tokenize("x = 10"); - assert_eq!(tokens.len(), 3); - assert_eq!(tokens[0].kind, TokenKind::Identifier("x".to_string())); - assert_eq!(tokens[1].kind, TokenKind::Assign); - assert_eq!(tokens[2].kind, TokenKind::Number(10.0)); - } - - #[test] - fn currency_dollar() { - let tokens = tokenize("$20"); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].kind, TokenKind::CurrencySymbol("$".to_string())); - assert_eq!(tokens[1].kind, TokenKind::Number(20.0)); - } - - #[test] - fn currency_euro() { - let tokens = tokenize("€15"); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].kind, TokenKind::CurrencySymbol("€".to_string())); - assert_eq!(tokens[1].kind, TokenKind::Number(15.0)); - } - - #[test] - fn currency_pound() { - let tokens = tokenize("£10"); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].kind, TokenKind::CurrencySymbol("£".to_string())); - assert_eq!(tokens[1].kind, TokenKind::Number(10.0)); - } - - #[test] - fn currency_yen() { - let tokens = tokenize("¥500"); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].kind, TokenKind::CurrencySymbol("¥".to_string())); - assert_eq!(tokens[1].kind, TokenKind::Number(500.0)); - } - - #[test] - fn currency_real() { - let tokens = tokenize("R$100"); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].kind, TokenKind::CurrencySymbol("R$".to_string())); - assert_eq!(tokens[1].kind, TokenKind::Number(100.0)); - } - - #[test] - fn unit_suffix_kg() { - let tokens = tokenize("5kg"); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].kind, TokenKind::Number(5.0)); - assert_eq!(tokens[1].kind, TokenKind::Unit("kg".to_string())); - } - - #[test] - fn unit_suffix_g() { - let tokens = tokenize("200g"); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].kind, TokenKind::Number(200.0)); - assert_eq!(tokens[1].kind, TokenKind::Unit("g".to_string())); - } - - #[test] - fn unit_suffix_m() { - let tokens = tokenize("3.5m"); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].kind, TokenKind::Number(3.5)); - assert_eq!(tokens[1].kind, TokenKind::Unit("m".to_string())); - } - - #[test] - fn percent_value() { - let tokens = tokenize("5%"); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].kind, TokenKind::Percent(5.0)); - } - - // ===== Task 6: Comments and text ===== - - #[test] - fn comment_line() { - let tokens = tokenize("// this is a note"); - assert_eq!(tokens.len(), 1); - assert_eq!( - tokens[0].kind, - TokenKind::Comment(" this is a note".to_string()) - ); - } - - #[test] - fn plain_text() { - let tokens = tokenize("hello world this is text"); - assert_eq!(tokens.len(), 1); - assert_eq!( - tokens[0].kind, - TokenKind::Text("hello world this is text".to_string()) - ); - } - - // ===== Task 7: Integration tests ===== - - #[test] - fn negative_number() { - let tokens = tokenize("-7"); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].kind, TokenKind::Operator(Operator::Minus)); - assert_eq!(tokens[1].kind, TokenKind::Number(7.0)); - } - - #[test] - fn mixed_expression_with_spans() { - let tokens = tokenize("$20 in euro - 5% discount"); - // Expect: CurrencySymbol($), Number(20), Keyword(in), Keyword(euro), Operator(-), Percent(5), Keyword(discount) - assert_eq!(tokens.len(), 7); - assert_eq!(tokens[0].kind, TokenKind::CurrencySymbol("$".to_string())); - assert_eq!(tokens[1].kind, TokenKind::Number(20.0)); - assert_eq!(tokens[2].kind, TokenKind::Keyword("in".to_string())); - assert_eq!(tokens[3].kind, TokenKind::Keyword("euro".to_string())); - assert_eq!(tokens[4].kind, TokenKind::Operator(Operator::Minus)); - assert_eq!(tokens[5].kind, TokenKind::Percent(5.0)); - assert_eq!(tokens[6].kind, TokenKind::Keyword("discount".to_string())); - - // Verify spans - assert_eq!(tokens[0].span, Span::new(0, 1)); // $ - assert_eq!(tokens[1].span, Span::new(1, 3)); // 20 - assert_eq!(tokens[4].span, Span::new(12, 13)); // - - } - - #[test] - fn spans_are_correct() { - let tokens = tokenize("42 + 3.14"); - assert_eq!(tokens[0].span, Span::new(0, 2)); // "42" - assert_eq!(tokens[1].span, Span::new(3, 4)); // "+" - assert_eq!(tokens[2].span, Span::new(5, 9)); // "3.14" - } - - #[test] - fn multiple_spaces() { - let tokens = tokenize("1 + 2"); - assert_eq!(tokens.len(), 3); - assert_eq!(tokens[0].kind, TokenKind::Number(1.0)); - assert_eq!(tokens[1].kind, TokenKind::Operator(Operator::Plus)); - assert_eq!(tokens[2].kind, TokenKind::Number(2.0)); - } - - #[test] - fn trailing_whitespace() { - let tokens = tokenize("42 "); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].kind, TokenKind::Number(42.0)); - } - - #[test] - fn inline_comment() { - let tokens = tokenize("42 + 8 // sum"); - assert_eq!(tokens.len(), 4); - assert_eq!(tokens[0].kind, TokenKind::Number(42.0)); - assert_eq!(tokens[1].kind, TokenKind::Operator(Operator::Plus)); - assert_eq!(tokens[2].kind, TokenKind::Number(8.0)); - assert_eq!(tokens[3].kind, TokenKind::Comment(" sum".to_string())); - } - - #[test] - fn keyword_in() { - let tokens = tokenize("100 in usd"); - assert_eq!(tokens.len(), 3); - assert_eq!(tokens[0].kind, TokenKind::Number(100.0)); - assert_eq!(tokens[1].kind, TokenKind::Keyword("in".to_string())); - assert_eq!(tokens[2].kind, TokenKind::Keyword("usd".to_string())); - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 6632088..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod token; -pub mod lexer; - -pub use lexer::tokenize; -pub use token::{Operator, Span, Token, TokenKind}; diff --git a/src/token.rs b/src/token.rs deleted file mode 100644 index 73f4307..0000000 --- a/src/token.rs +++ /dev/null @@ -1,129 +0,0 @@ -/// Byte offset span within the input string. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Span { - /// Inclusive start byte offset. - pub start: usize, - /// Exclusive end byte offset. - pub end: usize, -} - -impl Span { - /// Create a new span from start (inclusive) to end (exclusive). - pub fn new(start: usize, end: usize) -> Self { - Self { start, end } - } - - /// Length of the span in bytes. - pub fn len(&self) -> usize { - self.end - self.start - } - - /// Whether this span is empty. - pub fn is_empty(&self) -> bool { - self.start == self.end - } -} - -/// Arithmetic operator kind. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Operator { - Plus, - Minus, - Star, - Slash, - Caret, - Percent, -} - -/// The kind of token produced by the lexer. -#[derive(Debug, Clone, PartialEq)] -pub enum TokenKind { - /// A numeric literal (integer, decimal, scientific, or SI-scaled). - Number(f64), - /// An arithmetic operator. - Operator(Operator), - /// An identifier (variable name or function name). - Identifier(String), - /// The `=` assignment operator. - Assign, - /// A currency symbol ($, €, £, ¥, R$, etc.). - CurrencySymbol(String), - /// A unit suffix (kg, g, m, lb, etc.). - Unit(String), - /// A `//` comment — text after `//` to end of line. - Comment(String), - /// Plain text line with no calculable expression. - Text(String), - /// A keyword (in, to, as, of, discount, off, etc.). - Keyword(String), - /// A percentage value like `5%` — stores the number before `%`. - Percent(f64), - /// Left parenthesis `(`. - LParen, - /// Right parenthesis `)`. - RParen, -} - -/// A single token with its kind and byte span in the input. -#[derive(Debug, Clone, PartialEq)] -pub struct Token { - /// What kind of token this is and its associated value. - pub kind: TokenKind, - /// Byte span of this token in the original input. - pub span: Span, -} - -impl Token { - /// Create a new token. - pub fn new(kind: TokenKind, span: Span) -> Self { - Self { kind, span } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn span_new_and_len() { - let s = Span::new(0, 5); - assert_eq!(s.start, 0); - assert_eq!(s.end, 5); - assert_eq!(s.len(), 5); - assert!(!s.is_empty()); - } - - #[test] - fn span_empty() { - let s = Span::new(3, 3); - assert!(s.is_empty()); - assert_eq!(s.len(), 0); - } - - #[test] - fn token_construction() { - let tok = Token::new( - TokenKind::Number(42.0), - Span::new(0, 2), - ); - assert_eq!(tok.kind, TokenKind::Number(42.0)); - assert_eq!(tok.span, Span::new(0, 2)); - } - - #[test] - fn token_operator() { - let tok = Token::new( - TokenKind::Operator(Operator::Plus), - Span::new(0, 1), - ); - assert_eq!(tok.kind, TokenKind::Operator(Operator::Plus)); - } - - #[test] - fn token_kinds_equality() { - assert_eq!(TokenKind::Assign, TokenKind::Assign); - assert_eq!(TokenKind::LParen, TokenKind::LParen); - assert_eq!(TokenKind::RParen, TokenKind::RParen); - assert_ne!(TokenKind::LParen, TokenKind::RParen); - } -}