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.
This commit is contained in:
C. Cassel
2026-03-17 07:54:17 -04:00
committed by C. Cassel
parent 922b591d68
commit 6a8fecd03e
26 changed files with 5046 additions and 877 deletions

40
.gitignore vendored
View File

@@ -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/

656
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

13
calcpad-engine/Cargo.toml Normal file
View File

@@ -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"

View File

@@ -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 */

149
calcpad-engine/src/ast.rs Normal file
View File

@@ -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<T> {
pub node: T,
pub span: Span,
}
impl<T> Spanned<T> {
pub fn new(node: T, span: Span) -> Self {
Self { node, span }
}
}
/// Top-level expression AST.
pub type Expr = Spanned<ExprKind>;
#[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<Expr>,
right: Box<Expr>,
},
/// Unary negation: `-x`
UnaryNeg(Box<Expr>),
/// Percentage applied to a value: `100 - 20%`, `$100 + 10%`
PercentOp {
op: PercentOp,
base: Box<Expr>,
percentage: f64,
},
/// Currency conversion: `$20 in EUR`
Conversion {
expr: Box<Expr>,
target_currency: String,
},
/// Date range: `March 12 to July 30`
DateRange {
from: Box<Expr>,
to: Box<Expr>,
},
/// Comparison: `5 > 3`
Comparison {
op: CmpOp,
left: Box<Expr>,
right: Box<Expr>,
},
/// Variable reference: `x`, `total`
Identifier(String),
/// Variable assignment: `x = 5`
Assignment {
name: String,
value: Box<Expr>,
},
}
#[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, "!="),
}
}
}

View File

@@ -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<String, f64>,
/// The current date for resolving `today`.
pub today: NaiveDate,
/// Named variables set via assignment expressions (e.g., `x = 5`).
pub variables: HashMap<String, CalcResult>,
}
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<f64> {
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()
}
}

View File

@@ -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<String>, 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 {}

165
calcpad-engine/src/ffi.rs Normal file
View File

@@ -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<CalcResult>,
}
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<String> = 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));
}

View File

@@ -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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
match val {
Value::CurrencyValue { amount, currency } => {
if let Some(converted) = ctx.convert_currency(amount, &currency, 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<Value, String> {
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, &currency, 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<f64> {
// 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<f64> {
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,
}
}

430
calcpad-engine/src/lexer.rs Normal file
View File

@@ -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<Token>,
}
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<Token> {
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<u8> {
self.bytes.get(self.pos).copied()
}
fn peek_ahead(&self, n: usize) -> Option<u8> {
self.bytes.get(self.pos + n).copied()
}
fn advance(&mut self) -> Option<u8> {
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<Token> {
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<Token> {
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<Token> {
Lexer::new(input).tokenize()
}

21
calcpad-engine/src/lib.rs Normal file
View File

@@ -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};

View File

@@ -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<Number> {
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::<f64>().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::<f64>().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::<f64>().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<Number> {
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<Number> {
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<u8> = 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<std::cmp::Ordering> {
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<i64> for Number {
fn from(v: i64) -> Self {
Number::Integer(IBig::from(v))
}
}
impl From<i32> for Number {
fn from(v: i32) -> Self {
Number::Integer(IBig::from(v))
}
}
impl From<u64> for Number {
fn from(v: u64) -> Self {
Number::Integer(IBig::from(v))
}
}
impl From<f64> 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<Number>;
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<Number>;
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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Number {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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);
}
}

View File

@@ -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<Token>,
pos: usize,
}
impl Parser {
pub fn new(tokens: Vec<Token>) -> Self {
Self { tokens, pos: 0 }
}
/// Parse the entire token stream into an expression.
pub fn parse(mut self) -> Result<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<f64> {
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<String, ParseError> {
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<Token>) -> Result<Expr, ParseError> {
// 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<f64, ParseError> {
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,
)),
}
}

View File

@@ -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<CalcResult> {
lines.iter().map(|line| eval_line(line, ctx)).collect()
}

View File

@@ -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<Expr>,
/// The parse error, if any.
parse_error: Option<String>,
/// Variable name defined by this line (if assignment).
defines_var: Option<String>,
/// Variable names referenced by this line.
references_vars: Vec<String>,
/// 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<usize, LineEntry>,
/// Cached results per line from the last evaluation.
results: HashMap<usize, CalcResult>,
/// The maximum line index seen (for iteration order).
max_line: usize,
/// Tracks which lines have been modified since last eval.
dirty_lines: HashSet<usize>,
/// 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<CalcResult> {
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::<HashSet<_>>()
} 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<usize, HashSet<usize>> {
let line_indices = self.sorted_line_indices();
let var_to_line = self.build_var_to_line_map(&line_indices);
let mut graph: HashMap<usize, HashSet<usize>> = 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<usize> {
let mut indices: Vec<usize> = 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<String, usize> {
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<String, usize>,
) -> HashSet<usize> {
let mut circular = HashSet::new();
// Build adjacency list: line -> lines it depends on
let mut adj: HashMap<usize, Vec<usize>> = 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<usize, Vec<usize>>,
visited: &mut HashSet<usize>,
in_stack: &mut HashSet<usize>,
circular: &mut HashSet<usize>,
) {
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<String, usize>,
) -> HashSet<usize> {
let mut dirty = self.dirty_lines.clone();
// Build reverse map: line -> lines that depend on it
let mut dependents: HashMap<usize, Vec<usize>> = 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<usize> = 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<String> {
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<String> {
let mut vars = Vec::new();
collect_references(&expr.node, &mut vars);
vars
}
fn collect_references(node: &ExprKind, vars: &mut Vec<String>) {
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)");
}
}

View File

@@ -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),
}
}
}

View File

@@ -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 }
}
}

214
calcpad-engine/src/types.rs Normal file
View File

@@ -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<Span> 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<f64>,
}
/// 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)
}
}

View File

@@ -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);
}
}

24
calcpad-wasm/Cargo.toml Normal file
View File

@@ -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

170
calcpad-wasm/src/lib.rs Normal file
View File

@@ -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<f64>,
pub error: Option<String>,
}
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<String> = serde_wasm_bindgen::from_value(lines).unwrap_or_default();
let mut ctx = create_default_context();
let results: Vec<JsResult> = 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}"
);
}
}

View File

@@ -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

View File

@@ -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<Token>,
}
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<Token> {
// 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<u8> {
self.bytes.get(self.pos).copied()
}
/// Peek at the byte at offset `n` from current position.
fn peek_ahead(&self, n: usize) -> Option<u8> {
self.bytes.get(self.pos + n).copied()
}
/// Advance by one byte and return it.
fn advance(&mut self) -> Option<u8> {
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<Token> {
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<Token> {
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<Token> {
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()));
}
}

View File

@@ -1,5 +0,0 @@
pub mod token;
pub mod lexer;
pub use lexer::tokenize;
pub use token::{Operator, Span, Token, TokenKind};

View File

@@ -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);
}
}