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:
40
.gitignore
vendored
40
.gitignore
vendored
@@ -1 +1,41 @@
|
|||||||
|
# Build artifacts
|
||||||
/target
|
/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
656
Cargo.lock
generated
@@ -2,6 +2,662 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
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]]
|
[[package]]
|
||||||
name = "calcpad-engine"
|
name = "calcpad-engine"
|
||||||
version = "0.1.0"
|
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"
|
||||||
|
|||||||
13
Cargo.toml
13
Cargo.toml
@@ -1,7 +1,6 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "calcpad-engine"
|
members = [
|
||||||
version = "0.1.0"
|
"calcpad-engine",
|
||||||
edition = "2021"
|
"calcpad-wasm",
|
||||||
description = "Core calculation engine for CalcPad — a modern notepad calculator"
|
]
|
||||||
|
resolver = "2"
|
||||||
[dependencies]
|
|
||||||
|
|||||||
13
calcpad-engine/Cargo.toml
Normal file
13
calcpad-engine/Cargo.toml
Normal 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"
|
||||||
90
calcpad-engine/include/calcpad.h
Normal file
90
calcpad-engine/include/calcpad.h
Normal 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
149
calcpad-engine/src/ast.rs
Normal 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, "!="),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
calcpad-engine/src/context.rs
Normal file
77
calcpad-engine/src/context.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
30
calcpad-engine/src/error.rs
Normal file
30
calcpad-engine/src/error.rs
Normal 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
165
calcpad-engine/src/ffi.rs
Normal 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));
|
||||||
|
}
|
||||||
545
calcpad-engine/src/interpreter.rs
Normal file
545
calcpad-engine/src/interpreter.rs
Normal 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, ¤cy, target) {
|
||||||
|
Ok(Value::CurrencyValue {
|
||||||
|
amount: converted,
|
||||||
|
currency: target.to_string(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"no exchange rate available for {} to {}",
|
||||||
|
currency, target
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::UnitValue { value, unit } => {
|
||||||
|
if let Some(converted) = convert_units(value, &unit, target) {
|
||||||
|
Ok(Value::UnitValue {
|
||||||
|
value: converted,
|
||||||
|
unit: target.to_string(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(format!("cannot convert {} to {}", unit, target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Err("conversion requires a currency or unit value".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval_comparison(op: CmpOp, lval: Value, rval: Value) -> Result<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, ¤cy, span)
|
||||||
|
}
|
||||||
|
Value::DateTime(date) => CalcResult::datetime(date, span),
|
||||||
|
Value::TimeDelta { days, description } => CalcResult::time_delta(days, &description, span),
|
||||||
|
Value::Boolean(b) => CalcResult::boolean(b, span),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_to_days(value: f64, unit: DurationUnit) -> i64 {
|
||||||
|
let days = match unit {
|
||||||
|
DurationUnit::Days => value,
|
||||||
|
DurationUnit::Weeks => value * 7.0,
|
||||||
|
DurationUnit::Months => value * 30.0,
|
||||||
|
DurationUnit::Years => value * 365.0,
|
||||||
|
DurationUnit::Hours => value / 24.0,
|
||||||
|
DurationUnit::Minutes => value / (24.0 * 60.0),
|
||||||
|
DurationUnit::Seconds => value / (24.0 * 60.0 * 60.0),
|
||||||
|
};
|
||||||
|
days as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(value: f64, unit: DurationUnit) -> String {
|
||||||
|
let unit_str = match unit {
|
||||||
|
DurationUnit::Days => "days",
|
||||||
|
DurationUnit::Weeks => "weeks",
|
||||||
|
DurationUnit::Months => "months",
|
||||||
|
DurationUnit::Years => "years",
|
||||||
|
DurationUnit::Hours => "hours",
|
||||||
|
DurationUnit::Minutes => "minutes",
|
||||||
|
DurationUnit::Seconds => "seconds",
|
||||||
|
};
|
||||||
|
if value == 1.0 {
|
||||||
|
format!("1 {}", unit_str.trim_end_matches('s'))
|
||||||
|
} else {
|
||||||
|
format!("{} {}", value, unit_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_units(value: f64, from: &str, to: &str) -> Option<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
430
calcpad-engine/src/lexer.rs
Normal 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
21
calcpad-engine/src/lib.rs
Normal 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};
|
||||||
698
calcpad-engine/src/number.rs
Normal file
698
calcpad-engine/src/number.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
439
calcpad-engine/src/parser.rs
Normal file
439
calcpad-engine/src/parser.rs
Normal 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,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
40
calcpad-engine/src/pipeline.rs
Normal file
40
calcpad-engine/src/pipeline.rs
Normal 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()
|
||||||
|
}
|
||||||
683
calcpad-engine/src/sheet_context.rs
Normal file
683
calcpad-engine/src/sheet_context.rs
Normal 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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
20
calcpad-engine/src/span.rs
Normal file
20
calcpad-engine/src/span.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
calcpad-engine/src/token.rs
Normal file
70
calcpad-engine/src/token.rs
Normal 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
214
calcpad-engine/src/types.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
445
calcpad-engine/tests/ffi_tests.rs
Normal file
445
calcpad-engine/tests/ffi_tests.rs
Normal 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
24
calcpad-wasm/Cargo.toml
Normal 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
170
calcpad-wasm/src/lib.rs
Normal 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -336,7 +336,7 @@ phase_dev() {
|
|||||||
local dev_log="${LOG_DIR}/${TIMESTAMP}-dev-${story_name}.log"
|
local dev_log="${LOG_DIR}/${TIMESTAMP}-dev-${story_name}.log"
|
||||||
run_claude \
|
run_claude \
|
||||||
"Developing: $story_name" \
|
"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" \
|
"$dev_log" \
|
||||||
"$work_dir" || return 1
|
"$work_dir" || return 1
|
||||||
|
|
||||||
@@ -478,8 +478,26 @@ process_story_worktree() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log STEP "Creating worktree: $story_name → $branch"
|
log STEP "Creating worktree: $story_name → $branch"
|
||||||
git -C "$PROJECT_ROOT" branch "$branch" main 2>/dev/null || true
|
# Reset branch to main if it already exists, or create fresh
|
||||||
git -C "$PROJECT_ROOT" worktree add "$wt_path" "$branch" 2>/dev/null
|
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
|
# Symlink .claude/ so skills are available in worktree
|
||||||
if [ -d "${PROJECT_ROOT}/.claude" ] && [ ! -e "${wt_path}/.claude" ]; then
|
if [ -d "${PROJECT_ROOT}/.claude" ] && [ ! -e "${wt_path}/.claude" ]; then
|
||||||
|
|||||||
733
src/lexer.rs
733
src/lexer.rs
@@ -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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
pub mod token;
|
|
||||||
pub mod lexer;
|
|
||||||
|
|
||||||
pub use lexer::tokenize;
|
|
||||||
pub use token::{Operator, Span, Token, TokenKind};
|
|
||||||
129
src/token.rs
129
src/token.rs
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user