From fcd314cf9b0d4192a3a595ecf301aed01776c538 Mon Sep 17 00:00:00 2001 From: notKamui Date: Sat, 23 May 2026 17:43:28 +0200 Subject: [PATCH 1/4] Upgrade to version 2.0.0, introducing a generic number type API for Keval. This refactor allows for custom numeric types via the new `KevalNumber` interface, with `Double` as the default. Updated README and build files to reflect changes, including migration instructions from v1.x to v2.x. --- .../generic_keval_number_api_ce57ea4a.plan.md | 276 ++++++++++++++++++ README.md | 50 +++- build.gradle.kts | 2 +- .../com/notkamui/keval/AbstractSyntaxTree.kt | 78 ++--- .../kotlin/com/notkamui/keval/Grammar.kt | 40 +-- .../kotlin/com/notkamui/keval/Keval.kt | 51 ++-- .../kotlin/com/notkamui/keval/KevalBuilder.kt | 273 +++++------------ .../kotlin/com/notkamui/keval/KevalNumber.kt | 24 ++ .../com/notkamui/keval/KevalNumberDouble.kt | 132 +++++++++ .../kotlin/com/notkamui/keval/Tokenizer.kt | 23 +- .../kotlin/com/notkamui/keval/ASTTest.kt | 6 +- .../com/notkamui/keval/DSLResourcesTests.kt | 22 +- .../kotlin/com/notkamui/keval/GrammarTest.kt | 4 +- .../com/notkamui/keval/TokenizerTest.kt | 16 +- .../notkamui/keval/KevalNumberBigDecimal.kt | 117 ++++++++ .../keval/KevalBigDecimalArithmeticTest.kt | 136 +++++++++ .../keval/KevalBigDecimalBuilderTest.kt | 137 +++++++++ .../keval/KevalBigDecimalErrorsTest.kt | 48 +++ .../keval/KevalBigDecimalFunctionsTest.kt | 85 ++++++ .../keval/KevalBigDecimalLogicalTest.kt | 111 +++++++ .../keval/KevalBigDecimalParsingTest.kt | 72 +++++ .../keval/KevalBigDecimalTestSupport.kt | 14 + 22 files changed, 1399 insertions(+), 318 deletions(-) create mode 100644 .cursor/plans/generic_keval_number_api_ce57ea4a.plan.md create mode 100644 src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt create mode 100644 src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt create mode 100644 src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt create mode 100644 src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalArithmeticTest.kt create mode 100644 src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt create mode 100644 src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalErrorsTest.kt create mode 100644 src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalFunctionsTest.kt create mode 100644 src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalLogicalTest.kt create mode 100644 src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalParsingTest.kt create mode 100644 src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalTestSupport.kt diff --git a/.cursor/plans/generic_keval_number_api_ce57ea4a.plan.md b/.cursor/plans/generic_keval_number_api_ce57ea4a.plan.md new file mode 100644 index 0000000..1162b17 --- /dev/null +++ b/.cursor/plans/generic_keval_number_api_ce57ea4a.plan.md @@ -0,0 +1,276 @@ +--- +name: Generic Keval Number API +overview: Refactor Keval into a generic `Keval` pipeline driven by a `KevalNumber` typeclass, with `Double` as the common default and JVM/Android `BigDecimal` support (shared source set) with a minimal built-in set — no external dependencies. +todos: + - id: keval-number-interface + content: Add KevalNumber interface and KevalNumbers object with Double entry point + status: completed + - id: generic-pipeline + content: Parameterize AbstractSyntaxTree, Grammar, Tokenizer on N; thread KevalNumber through parsing + status: completed + - id: generic-keval-builder + content: Refactor Keval and KevalBuilder; move DEFAULT_RESOURCES to KevalNumberDouble + status: completed + - id: jvm-bigdecimal + content: Add jvmAndAndroidMain source set, android() target, and KevalNumberBigDecimal with arithmetic-only defaults + status: completed + - id: tests-and-docs + content: Update commonTest for generic Double API; add jvmTest for BigDecimal; bump to 2.0.0 and update README + status: completed +isProject: false +--- + +# Generic Number Type Support for Keval + +## Current State + +The entire pipeline is hardcoded to `Double`: + +```mermaid +flowchart LR + expr[String] --> tok[Tokenizer.isNumeric via toDoubleOrNull] + tok --> parse[Parser ValueNode via toDouble] + parse --> ast["Node.eval(): Double"] + ast --> api["Keval.eval(): Double"] +``` + +Every layer in [`src/commonMain/kotlin/com/notkamui/keval/`](src/commonMain/kotlin/com/notkamui/keval/) uses `(Double, Double) -> Double`, `DoubleArray`, and `kotlin.math` — there is no platform-specific code today. + +## Target Architecture + +Introduce a **typeclass** that owns parsing rules and default resources for a numeric type `N`, then parameterize the full pipeline on `N`. + +```mermaid +flowchart LR + kn["KevalNumber<N>"] --> tok[Tokenizer] + kn --> parse[Parser] + kn --> defaults[defaultResources] + parse --> ast["Node<N>.eval(): N"] + ast --> api["Keval<N>.eval(): N"] + defaults --> builder[KevalBuilder<N>] +``` + +### New core abstraction: `KevalNumber` + +Add [`KevalNumber.kt`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt): + +```kotlin +interface KevalNumber { + fun isValidLiteral(token: String): Boolean + fun parseLiteral(token: String): N + fun defaultResources(): Map> +} +``` + +- **Parsing** moves out of `toDoubleOrNull()` / `toDouble()` into the typeclass. +- **Default resources** move out of `KevalBuilder.DEFAULT_RESOURCES` into each implementation (Double keeps today's full set; BigDecimal gets a **minimal** arithmetic-focused subset — see below). + +Consumers can implement `KevalNumber` for any type (`Int`, a custom decimal, etc.) by supplying parsing + whatever defaults they want. + +--- + +## Breaking API Changes (v2.0.0) + +| Before (v1.x) | After (v2.x) | +|---|---| +| `class Keval` | `class Keval(private val number: KevalNumber, ...)` | +| `(Double, Double) -> Double` operators | `(N, N) -> N` | +| `(DoubleArray) -> Double` functions | `(List) -> N` | +| `KevalBuilder()` with static `DEFAULT_RESOURCES` | `KevalBuilder(number: KevalNumber)` | +| `String.keval(): Double` | kept as convenience, delegates to `KevalNumbers.Double` | +| `Keval.create { includeDefault() }` | `Keval.create(KevalNumbers.Double) { includeDefault() }` | + +### Convenience entry points (preserve ergonomics for Double) + +```kotlin +object KevalNumbers { + val Double: KevalNumber = KevalNumberDouble +} + +// unchanged call sites for the common case +fun String.keval(): Double = KevalNumbers.Double.eval(this) +fun String.keval(generator: KevalBuilder.() -> Unit): Double = + Keval.create(KevalNumbers.Double, generator).eval(this) + +// JVM + Android BigDecimal (java.math.BigDecimal on both platforms) +val KevalNumbers.BigDecimal: KevalNumber +``` + +`Keval.eval(expr)` companion shortcut stays as `@JvmStatic` Double-only sugar. + +--- + +## File-by-File Refactor + +### 1. Generic operator / AST types — [`AbstractSyntaxTree.kt`](src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt) + +Parameterize all internal types on `N`: + +- `KevalBinaryOperator(..., implementation: (N, N) -> N)` +- `KevalUnaryOperator(..., implementation: (N) -> N)` +- `KevalFunction(..., implementation: (List) -> N)` +- `KevalConstant(value: N)` +- `Node` with `fun eval(): N` +- `FunctionNode` collects `children.map { it.eval() }` as `List` (not `DoubleArray`) + +`KevalOperator` becomes `KevalOperator` sealed interface. + +### 2. Parser — [`Grammar.kt`](src/commonMain/kotlin/com/notkamui/keval/Grammar.kt) + +- `Parser` takes `KevalNumber` alongside operators map. +- Replace `String.isDouble()` / `token.toDouble()` with `number.isValidLiteral(token)` / `number.parseLiteral(token)`. +- `String.toAST(number: KevalNumber, operators: Map>): Node`. + +### 3. Tokenizer — [`Tokenizer.kt`](src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt) + +- `String.isNumeric(number: KevalNumber)` delegates to `number.isValidLiteral(this)`. +- `normalizeTokens` / `tokenize` accept `KevalNumber` (threaded from `toAST`). +- Extend `TOKENIZER_REGEX` to support scientific notation (`1.23e4`, `1e-10`) — needed for BigDecimal literals and improves Double parsing. Keep backward-compatible plain integer/decimal forms. + +### 4. Builder — [`KevalBuilder.kt`](src/commonMain/kotlin/com/notkamui/keval/KevalBuilder.kt) + +- `class KevalBuilder(private val number: KevalNumber, baseResources: ... = emptyMap())` +- `includeDefault()` → `resources += number.defaultResources()` +- All builder `implementation` fields become `(N, N) -> N`, `(N) -> N`, `(List) -> N`. +- `build(): Keval` +- **Delete** the 100+ line `DEFAULT_RESOURCES` companion — it moves to `KevalNumberDouble`. + +### 5. Public API — [`Keval.kt`](src/commonMain/kotlin/com/notkamui/keval/Keval.kt) + +- `class Keval internal constructor(private val number: KevalNumber, private val resources: ...)` +- All `with*` methods take generic lambdas; return `Keval`. +- `eval(mathExpression: String): N` calls `mathExpression.toAST(number, resourcesView()).eval()`. +- `companion object.create(number: KevalNumber, generator: KevalBuilder.() -> Unit): Keval` + +### 6. Double implementation — new [`KevalNumberDouble.kt`](src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt) + +Move today's [`KevalBuilder.DEFAULT_RESOURCES`](src/commonMain/kotlin/com/notkamui/keval/KevalBuilder.kt) verbatim into `KevalNumberDouble.defaultResources()`, adapting signatures to `(N, N) -> N` / `List`. + +Parsing: `isValidLiteral` → `toDoubleOrNull() != null`; `parseLiteral` → `toDouble()`. + +Extract shared boolean helpers (`doubleToBoolean`, `reduceBoolean`, etc.) as private functions in this file. + +--- + +## JVM / Android BigDecimal Support + +No external dependencies — only `java.math.BigDecimal` and `MathContext`, available on both JVM and Android. + +### Platform availability + +| Target | BigDecimal API | Notes | +|---|---|---| +| JVM | Yes | via `jvmAndAndroidMain` | +| Android | Yes | via `jvmAndAndroidMain` (same implementation) | +| JS / Native (iOS, etc.) | No | `KevalNumbers.Double` only; types not compiled in | + +**Why not `jvmMain` alone?** In KMP, `jvmMain` is compiled only into the JVM artifact. Android uses a separate `androidMain` source set — code in `jvmMain` is invisible to Android consumers even though `java.math.BigDecimal` exists on Android. + +### New targets and source sets in [`build.gradle.kts`](build.gradle.kts) + +```kotlin +kotlin { + jvm() + androidTarget() // new + + // ... existing js/native targets ... + + sourceSets { + val jvmAndAndroidMain by creating { + dependsOn(commonMain.get()) + } + jvmMain.get().dependsOn(jvmAndAndroidMain) + androidMain.get().dependsOn(jvmAndAndroidMain) + + val jvmAndAndroidTest by creating { + dependsOn(commonTest.get()) + } + jvmTest.get().dependsOn(jvmAndAndroidTest) + androidUnitTest.get().dependsOn(jvmAndAndroidTest) + } +} +``` + +BigDecimal implementation lives in `jvmAndAndroidMain` — compiled into both JVM and Android publications. No extra dependencies. + +### New file — [`src/jvmAndAndroidMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt`](src/jvmAndAndroidMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) + +```kotlin +object KevalNumberBigDecimal : KevalNumber { + val mathContext: MathContext = MathContext.DECIMAL128 + + override fun isValidLiteral(token: String): Boolean = ... + override fun parseLiteral(token: String): BigDecimal = ... + override fun defaultResources(): Map> = ... +} +``` + +**Included in BigDecimal defaults** (native `BigDecimal` / `compareTo` / `RoundingMode` only): + +| Category | Operators / functions | +|---|---| +| Binary | `+`, `-`, `*`, `/`, `%`, `^` (integer exponent via `pow(int)` only) | +| Unary | `-` (negate), `+` (identity) | +| Functions | `neg`, `abs`, `sign`, `min`, `max`, `sum`, `avg`, `ceil`, `floor`, `round`, `trunc` | +| Comparison / logical | `bool`, `not`, `and`, `nand`, `or`, `nor`, `xor`, `xnor`, `imply`, `nimply`, `eq`, `ne`, `gt`, `lt`, `ge`, `le` | +| Constants | none (consumers can add `PI` / `e` via `withConstant` or a custom `KevalNumber` wrapper) | + +Implementation notes: +- Div-by-zero: `compareTo(ZERO) == 0` → `KevalZeroDivisionException` +- Truthiness: `compareTo(ZERO) != 0` +- `^` with non-integer exponent: throw `KevalInvalidArgumentException` (no approximation library) +- `avg`: `sum / size` using configured `MathContext` +- Rounding ops: `setScale` / `RoundingMode` + +**Explicitly excluded** (require transcendental math or heavy custom code — users add via `KevalBuilder` if needed): + +- Trig: `sin`, `cos`, `tan`, `asin`, `acos`, `atan` +- Roots / powers: `sqrt`, `cbrt`, `nthrt`, fractional `^` +- Logs / exp: `exp`, `ln`, `log10`, `log2` +- Random: `rand`, `randRange` +- Other: `!` (factorial), `median`, `percentile`, constants `PI` / `e` + +Expose via `KevalNumbers.BigDecimal` in the same `jvmAndAndroidMain` file (or a small `KevalNumbers.jvmAndAndroid.kt`). + +### JVM / Android convenience extensions + +```kotlin +fun String.kevalBigDecimal( + generator: KevalBuilder.() -> Unit = { includeDefault() } +): BigDecimal = Keval.create(KevalNumberBigDecimal, generator).eval(this) +``` + +--- + +## Tests + +Update all tests in [`src/commonTest/`](src/commonTest/kotlin/com/notkamui/keval/) to use `KevalNumbers.Double` / `Keval.create(KevalNumbers.Double)`. + +Add [`src/jvmAndAndroidTest/kotlin/com/notkamui/keval/KevalBigDecimalTest.kt`](src/jvmAndAndroidTest/kotlin/com/notkamui/keval/KevalBigDecimalTest.kt) (runs on both JVM and Android unit tests): + +- Precision cases that fail with Double (e.g. `0.1 + 0.2`). +- Smoke test each included default category (arithmetic, comparison, logical, aggregates, rounding). +- Div-by-zero, non-integer `^`, and invalid-argument paths. +- Confirm excluded functions (e.g. `sin`) are **not** in defaults but can be added via builder. + +Existing Double tests should remain green with minimal assertion changes (same expected values). + +--- + +## Versioning and Docs + +- Bump version to **2.0.0** in [`build.gradle.kts`](build.gradle.kts). +- Update [`README.md`](README.md) with: + - Generic API section (`KevalNumber`, `Keval.create(number) { ... }`). + - JVM/Android BigDecimal section with `KevalNumbers.BigDecimal` / `kevalBigDecimal()`, documenting the **reduced** default set vs Double and which platforms support it. + - Migration guide from v1.x (type signature changes, `KevalBuilder` now requires a number context). + +--- + +## Design Notes / Non-Goals + +- **JS / Native targets** remain Double-only; BigDecimal types are not compiled into those artifacts. +- **Android**: added as a new publish target; shares BigDecimal code with JVM via `jvmAndAndroidMain`. +- **`MathContext` configurability**: ship with `DECIMAL128` default; a follow-up could add `KevalNumberBigDecimal.withContext(MathContext)` if consumers need per-evaluation precision control. +- **BigDecimal transcendental functions**: intentionally out of scope for built-in defaults; consumers can register custom functions via `KevalBuilder` (possibly wrapping a third-party decimal math lib themselves). +- **Custom numeric types**: fully supported — implement `KevalNumber` and pass it to `Keval.create`; only parsing + defaults need type-specific logic; tokenizer/parser/AST are generic. diff --git a/README.md b/README.md index 7149d67..a3ac15e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Maven com.notkamui.libs keval - 1.2.0 + 2.0.0 ``` @@ -33,7 +33,7 @@ repositories { } dependencies { - implementation("com.notkamui.libs:keval:1.2.0") + implementation("com.notkamui.libs:keval:2.0.0") } ``` @@ -147,7 +147,7 @@ Keval.eval("(3+4)(2/8 * 5) % PI") // uses default resources "(3+4)(2/8 * 5) % PI".keval() // extension ; uses default resources -Keval.create { // builder instance +Keval.create(KevalNumbers.Double) { // builder instance includeDefault() // this function includes the built-in resources binaryOperator { // this function adds a binary operator ; you can call it several times @@ -220,8 +220,7 @@ many `eval` as you need. In concordance with creating a Keval instance, you can also add resources like this: ```Kotlin -val kvl = Keval().create {} - .withDefault() // includes default resources // it is unnecessary here since Keval() with no DSL already does it +val kvl = Keval.create(KevalNumbers.Double) { includeDefault() } .withBinaryOperator( // includes a new binary operator ';', // symbol 3, // precedence @@ -261,6 +260,47 @@ operator to In addition, the symbols `(`,`)`,`,` are reserved and trying to create operator using one of those symbols will result with an exception. +## Generic number types + +Keval is generic over the numeric result type via [KevalNumber](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt). The default is [Double](src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt) on all platforms (JVM, JS, Native, Android via `commonMain`). + +```Kotlin +// Custom numeric type: implement KevalNumber and pass it to Keval.create +Keval.create(myNumber) { + includeDefault() + function { + name = "twice" + arity = 1 + implementation = { args -> args[0] + args[0] } + } +}.eval("twice(21)") +``` + +Function implementations take `List` instead of `DoubleArray`. The `String.keval()` extension and `Keval.eval(String)` companion remain `Double`-only shortcuts. + +### BigDecimal (JVM only) + +On the JVM artifact, [KevalNumberBigDecimal](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) provides a reduced default set (arithmetic, comparison, aggregates, rounding — no trig/log/random). Other targets continue to use `Double` through `commonMain`. + +```Kotlin +"0.1 + 0.2".kevalBigDecimal() // BigDecimal("0.3") + +Keval.create(KevalNumbers.BigDecimal) { + includeDefault() +}.eval("sum(1, 2, 3)") +``` + +### Migrating from v1.x + +| v1.x | v2.x | +|---|---| +| `Keval.create { includeDefault() }` | `Keval.create(KevalNumbers.Double) { includeDefault() }` | +| `(Double, Double) -> Double` operators | `(N, N) -> N` | +| `(DoubleArray) -> Double` functions | `(List) -> N` | +| `KevalBuilder.DEFAULT_RESOURCES` | `KevalNumbers.Double.defaultResources()` | + +`String.keval()` and `Keval.eval(expr)` are unchanged for `Double`. + ## Error Handling In case of an error, Keval will throw one of several `KevalException`s: diff --git a/build.gradle.kts b/build.gradle.kts index 4937f79..c7de0da 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ plugins { val artifactId = "keval" group = "com.notkamui.libs" -version = "1.2.0" +version = "2.0.0" repositories { mavenCentral() diff --git a/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt b/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt index 48c0cf0..d8e9a29 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt @@ -3,7 +3,7 @@ package com.notkamui.keval /** * Represents an operator, may be either a binary operator, a unary operator, a function, or a constant */ -sealed interface KevalOperator +sealed interface KevalOperator /** * Represents a binary operator @@ -12,21 +12,21 @@ sealed interface KevalOperator * @property isLeftAssociative is true if the operator is left associative, false otherwise * @property implementation is the actual implementation of the operator */ -internal data class KevalBinaryOperator( +internal data class KevalBinaryOperator( val precedence: Int, val isLeftAssociative: Boolean, - val implementation: (Double, Double) -> Double -) : KevalOperator + val implementation: (N, N) -> N +) : KevalOperator -internal data class KevalUnaryOperator( +internal data class KevalUnaryOperator( val isPrefix: Boolean, - val implementation: (Double) -> Double, -) : KevalOperator + val implementation: (N) -> N, +) : KevalOperator -internal data class KevalBothOperator( - val binary: KevalBinaryOperator, - val unary: KevalUnaryOperator, -) : KevalOperator +internal data class KevalBothOperator( + val binary: KevalBinaryOperator, + val unary: KevalUnaryOperator, +) : KevalOperator /** * Represents a function @@ -34,33 +34,33 @@ internal data class KevalBothOperator( * @property arity is the arity of the function (how many arguments it takes). If null, the function is variadic * @property implementation is the actual implementation of the function */ -internal data class KevalFunction( +internal data class KevalFunction( val arity: Int?, - val implementation: (DoubleArray) -> Double -) : KevalOperator + val implementation: (List) -> N +) : KevalOperator /** * Represents a constant * * @property value is the value of the constant */ -internal data class KevalConstant( - val value: Double -) : KevalOperator +internal data class KevalConstant( + val value: N +) : KevalOperator /** * Represents a node in an AST and can evaluate its value * * Can either be an operator, or a leaf (a value) */ -internal interface Node { +internal interface Node { /** * Evaluates the value of this node * * @return the value of the node * @throws KevalZeroDivisionException in case of a zero division */ - fun eval(): Double + fun eval(): N } /** @@ -71,26 +71,26 @@ internal interface Node { * @property right is its right child * @constructor Creates an operator node */ -internal data class BinaryOperatorNode( - private val left: Node, - private val op: (Double, Double) -> Double, - private val right: Node -) : Node { - override fun eval(): Double = op(left.eval(), right.eval()) +internal data class BinaryOperatorNode( + private val left: Node, + private val op: (N, N) -> N, + private val right: Node +) : Node { + override fun eval(): N = op(left.eval(), right.eval()) } -internal data class UnaryOperatorNode( - private val op: (Double) -> Double, - private val child: Node -) : Node { - override fun eval(): Double = op(child.eval()) +internal data class UnaryOperatorNode( + private val op: (N) -> N, + private val child: Node +) : Node { + override fun eval(): N = op(child.eval()) } -internal data class FunctionNode( - private val func: (DoubleArray) -> Double, - private val children: List -) : Node { - override fun eval(): Double = func(children.map(Node::eval).toDoubleArray()) +internal data class FunctionNode( + private val func: (List) -> N, + private val children: List> +) : Node { + override fun eval(): N = func(children.map { it.eval() }) } /** @@ -99,8 +99,8 @@ internal data class FunctionNode( * @property value is its value * @constructor Creates a value node */ -internal data class ValueNode( - private val value: Double -) : Node { - override fun eval(): Double = value +internal data class ValueNode( + private val value: N +) : Node { + override fun eval(): N = value } diff --git a/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt b/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt index ece5c2e..6ab0632 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt @@ -1,12 +1,12 @@ package com.notkamui.keval -private fun String.isDouble(): Boolean = this.toDoubleOrNull() != null private fun String.pluralize(count: Int): String = if (count == 1) this else "${this}s" -internal class Parser( +internal class Parser( + private val number: KevalNumber, private val tokens: Iterator, private val tokensToString: String, - private val operators: Map + private val operators: Map> ) { private var currentTokenOrNull: String? = tokens.next() private val currentToken: String @@ -52,7 +52,7 @@ internal class Parser( (it is KevalUnaryOperator && !it.isPrefix) || (it is KevalBothOperator && !it.unary.isPrefix) } - private fun getBinaryOperator(token: String): KevalBinaryOperator = operators[token].let { + private fun getBinaryOperator(token: String): KevalBinaryOperator = operators[token].let { if (it is KevalBothOperator) { it.binary } else { @@ -60,7 +60,7 @@ internal class Parser( } } - private fun getUnaryOperator(token: String): KevalUnaryOperator = operators[token].let { + private fun getUnaryOperator(token: String): KevalUnaryOperator = operators[token].let { if (it is KevalBothOperator) { it.unary } else { @@ -68,7 +68,7 @@ internal class Parser( } } - private fun handleBinaryOperator(node: Node, minPrecedence: Int): Node { + private fun handleBinaryOperator(node: Node, minPrecedence: Int): Node { var result = node while (currentTokenOrNull != null && isBinaryOrBoth(currentToken)) { val op = getBinaryOperator(currentToken) @@ -80,18 +80,18 @@ internal class Parser( return result } - private fun handleUnaryOperator(node: Node? = null): Node { + private fun handleUnaryOperator(node: Node? = null): Node { val op = getUnaryOperator(currentToken) consume(currentToken) return UnaryOperatorNode(op.implementation, node ?: primary()) } - private fun handleFunction(): Node { + private fun handleFunction(): Node { val functionName = currentToken consume(functionName) val op = operators[functionName] as KevalFunction consume("(") - val args = mutableListOf() + val args = mutableListOf>() while (currentTokenOrNull != ")") { args.add(expression()) if (op.arity != null && args.size > op.arity) { @@ -116,13 +116,13 @@ internal class Parser( return FunctionNode(op.implementation, args) } - private fun handleConstant(): Node { + private fun handleConstant(): Node { val op = operators[currentToken] as KevalConstant consume(currentToken) return ValueNode(op.value) } - private fun expression(minPrecedence: Int = 0): Node { + private fun expression(minPrecedence: Int = 0): Node { var node = primary() while (currentTokenOrNull != null && isUnaryOrBothPostfix(currentToken)) { node = handleUnaryOperator(node) @@ -131,7 +131,7 @@ internal class Parser( return node } - private fun primary(): Node { + private fun primary(): Node { if (currentTokenOrNull != null && isUnaryOrBothPrefix(currentToken)) { return handleUnaryOperator() } else if (currentTokenOrNull == "(") { @@ -148,7 +148,7 @@ internal class Parser( } } val token = currentToken - if (!token.isDouble()) { + if (!number.isValidLiteral(token)) { throw KevalInvalidExpressionException( tokensToString, currentPos, @@ -156,11 +156,10 @@ internal class Parser( ) } consume(currentToken) - val node = ValueNode(token.toDouble()) - return node + return ValueNode(number.parseLiteral(token)) } - fun parse(): Node { + fun parse(): Node { val node = expression() if (currentTokenOrNull != null) { throw KevalInvalidExpressionException( @@ -182,13 +181,16 @@ internal class Parser( * @throws KevalInvalidSymbolException if the expression contains an invalid symbol * @throws KevalInvalidExpressionException if the expression is invalid (i.e. mismatched parenthesis, missing operand, or empty expression) */ -internal fun String.toAST(operators: Map): Node { +internal fun String.toAST( + number: KevalNumber, + operators: Map>, +): Node { if (this.replace("""[()]""".toRegex(), "").isBlank()) throw KevalInvalidExpressionException("", -1) - val tokens = this.tokenize(operators) + val tokens = this.tokenize(number, operators) val tokensToString = tokens.joinToString("") - val parser = Parser(tokens.iterator(), tokensToString, operators) + val parser = Parser(number, tokens.iterator(), tokensToString, operators) return parser.parse() } diff --git a/src/commonMain/kotlin/com/notkamui/keval/Keval.kt b/src/commonMain/kotlin/com/notkamui/keval/Keval.kt index 5bef6e8..8dadc54 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/Keval.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/Keval.kt @@ -7,7 +7,10 @@ import kotlin.jvm.JvmStatic * Main class for evaluating mathematical expressions. * It can be customized with additional operators, functions, and constants. */ -class Keval internal constructor(private val resources: Map) { +class Keval internal constructor( + private val number: KevalNumber, + private val resources: Map> +) { /** * Creates a new instance which contains a binary operator. @@ -23,8 +26,8 @@ class Keval internal constructor(private val resources: Map Double - ): Keval = KevalBuilder(resources) + implementation: (N, N) -> N + ): Keval = KevalBuilder(number, resources) .binaryOperator { this.symbol = symbol this.precedence = precedence @@ -46,8 +49,8 @@ class Keval internal constructor(private val resources: Map Double - ): Keval = KevalBuilder(resources) + implementation: (N) -> N + ): Keval = KevalBuilder(number, resources) .unaryOperator { this.symbol = symbol this.isPrefix = isPrefix @@ -67,8 +70,8 @@ class Keval internal constructor(private val resources: Map Double - ): Keval = KevalBuilder(resources) + implementation: (List) -> N + ): Keval = KevalBuilder(number, resources) .function { this.name = name this.arity = arity @@ -86,8 +89,8 @@ class Keval internal constructor(private val resources: Map = KevalBuilder(number, resources) .constant { this.name = name this.value = value @@ -99,7 +102,7 @@ class Keval internal constructor(private val resources: Map = KevalBuilder(number, resources).includeDefault().build() /** * Evaluates a mathematical expression. @@ -112,33 +115,39 @@ class Keval internal constructor(private val resources: Map = - resources + ("*" to KevalBinaryOperator(3, true) { a, b -> a * b }) + fun resourcesView(): Map> = + resources + ("*" to requireNotNull(number.defaultResources()["*"]) { + "Number type must define a default * operator" + }) companion object { /** * Creates a new instance of [Keval] with the provided resources. * + * @param number The numeric type context for parsing and default resources. * @param generator A lambda function that configures a KevalBuilder instance. * @return The new instance of Keval. * @throws KevalDSLException If one of the fields isn't set properly. */ @JvmStatic - fun create(generator: KevalBuilder.() -> Unit = { includeDefault() }): Keval = - KevalBuilder().apply(generator).build() + fun create( + number: KevalNumber, + generator: KevalBuilder.() -> Unit = { includeDefault() } + ): Keval = + KevalBuilder(number).apply(generator).build() /** - * Evaluates a mathematical expression using the default resources. + * Evaluates a mathematical expression using the default Double resources. * * @param mathExpression The mathematical expression to evaluate. * @return The result of the evaluation. @@ -150,7 +159,7 @@ class Keval internal constructor(private val resources: Map Unit -): Double = KevalBuilder().apply(generator).build().eval(this) + generator: KevalBuilder.() -> Unit +): Double = Keval.create(KevalNumbers.Double, generator).eval(this) /** * Evaluates a mathematical expression using the default resources. @@ -178,4 +187,4 @@ fun String.keval( * @throws KevalInvalidExpressionException If the expression is invalid (i.e., mismatched parentheses). * @throws KevalZeroDivisionException If a division by zero occurs. */ -fun String.keval(): Double = Keval.eval(this) +fun String.keval(): Double = KevalNumbers.Double.eval(this) diff --git a/src/commonMain/kotlin/com/notkamui/keval/KevalBuilder.kt b/src/commonMain/kotlin/com/notkamui/keval/KevalBuilder.kt index 203a6ca..9c99e38 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/KevalBuilder.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/KevalBuilder.kt @@ -1,21 +1,19 @@ package com.notkamui.keval -import kotlin.math.* -import kotlin.random.Random - /** * This class is used to build a Keval instance with custom operators, functions, and constants. */ -class KevalBuilder internal constructor( - baseResources: Map = mapOf() +class KevalBuilder internal constructor( + private val number: KevalNumber, + baseResources: Map> = mapOf() ) { - private val resources: MutableMap = baseResources.toMutableMap() + private val resources: MutableMap> = baseResources.toMutableMap() /** * Includes the default resources (operators, functions, constants) to the current Keval instance. */ - fun includeDefault(): KevalBuilder = apply { - resources += DEFAULT_RESOURCES + fun includeDefault(): KevalBuilder = apply { + resources += number.defaultResources() } /** @@ -23,8 +21,8 @@ class KevalBuilder internal constructor( * * @param definition A lambda function that configures a BinaryOperatorBuilder instance. */ - fun binaryOperator(definition: BinaryOperatorBuilder.() -> Unit): KevalBuilder = apply { - val op = BinaryOperatorBuilder().apply(definition) + fun binaryOperator(definition: BinaryOperatorBuilder.() -> Unit): KevalBuilder = apply { + val op = BinaryOperatorBuilder().apply(definition) validateOperator(op.symbol, op.precedence, op.implementation) addOperator(op.symbol!!, KevalBinaryOperator(op.precedence!!, op.isLeftAssociative!!, op.implementation!!), isUnary = false) } @@ -34,8 +32,8 @@ class KevalBuilder internal constructor( * * @param definition A lambda function that configures a UnaryOperatorBuilder instance. */ - fun unaryOperator(definition: UnaryOperatorBuilder.() -> Unit): KevalBuilder = apply { - val op = UnaryOperatorBuilder().apply(definition) + fun unaryOperator(definition: UnaryOperatorBuilder.() -> Unit): KevalBuilder = apply { + val op = UnaryOperatorBuilder().apply(definition) validateUnaryOperator(op.symbol, op.isPrefix, op.implementation) addOperator(op.symbol!!, KevalUnaryOperator(op.isPrefix!!, op.implementation!!), isUnary = true) } @@ -45,8 +43,8 @@ class KevalBuilder internal constructor( * * @param definition A lambda function that configures a FunctionBuilder instance. */ - fun function(definition: FunctionBuilder.() -> Unit): KevalBuilder = apply { - val fn = FunctionBuilder().apply(definition) + fun function(definition: FunctionBuilder.() -> Unit): KevalBuilder = apply { + val fn = FunctionBuilder().apply(definition) validateFunction(fn.name, fn.arity, fn.implementation) resources[fn.name!!] = KevalFunction(fn.arity, fn.implementation!!) } @@ -56,8 +54,8 @@ class KevalBuilder internal constructor( * * @param definition A lambda function that configures a ConstantBuilder instance. */ - fun constant(definition: ConstantBuilder.() -> Unit): KevalBuilder = apply { - val const = ConstantBuilder().apply(definition) + fun constant(definition: ConstantBuilder.() -> Unit): KevalBuilder = apply { + val const = ConstantBuilder().apply(definition) validateConstant(const.name, const.value) resources[const.name!!] = KevalConstant(const.value!!) } @@ -67,9 +65,9 @@ class KevalBuilder internal constructor( * * @return A Keval instance. */ - fun build(): Keval = Keval(resources) + fun build(): Keval = Keval(number, resources) - private fun validateOperator(symbol: Char?, precedence: Int?, implementation: ((Double, Double) -> Double)?) { + private fun validateOperator(symbol: Char?, precedence: Int?, implementation: ((N, N) -> N)?) { requireNotNull(symbol) { "symbol is not set" } requireNotNull(implementation) { "implementation is not set" } requireNotNull(precedence) { "precedence is not set" } @@ -78,7 +76,7 @@ class KevalBuilder internal constructor( require(symbol != '*') { "* cannot be overwritten" } } - private fun validateUnaryOperator(symbol: Char?, isPrefix: Boolean?, implementation: ((Double) -> Double)?) { + private fun validateUnaryOperator(symbol: Char?, isPrefix: Boolean?, implementation: ((N) -> N)?) { requireNotNull(symbol) { "symbol is not set" } requireNotNull(isPrefix) { "isPrefix is not set" } requireNotNull(implementation) { "implementation is not set" } @@ -92,7 +90,7 @@ class KevalBuilder internal constructor( require(name.isFunctionOrConstantName()) { "a function's name cannot start with a digit and must contain only letters, digits or underscores: $name" } } - private fun validateConstant(name: String?, value: Double?) { + private fun validateConstant(name: String?, value: N?) { requireNotNull(name) { "name is not set" } requireNotNull(value) { "value is not set" } require(name.isFunctionOrConstantName()) { "a constant's name cannot start with a digit and must contain only letters, digits or underscores: $name" } @@ -102,198 +100,73 @@ class KevalBuilder internal constructor( private fun String.isFunctionOrConstantName() = isNotEmpty() && this[0] !in '0'..'9' && !contains("[^a-zA-Z0-9_]".toRegex()) - private fun addOperator(symbol: Char, operator: KevalOperator, isUnary: Boolean) { + private fun addOperator(symbol: Char, operator: KevalOperator, isUnary: Boolean) { when (val resource = resources[symbol.toString()]) { is KevalUnaryOperator -> resources[symbol.toString()] = - if (isUnary) operator as KevalUnaryOperator - else KevalBothOperator(operator as KevalBinaryOperator, resource) + if (isUnary) operator as KevalUnaryOperator + else KevalBothOperator(operator as KevalBinaryOperator, resource) is KevalBinaryOperator -> resources[symbol.toString()] = - if (isUnary) KevalBothOperator(resource, operator as KevalUnaryOperator) - else operator as KevalBinaryOperator + if (isUnary) KevalBothOperator(resource, operator as KevalUnaryOperator) + else operator as KevalBinaryOperator is KevalBothOperator -> resources[symbol.toString()] = - if (isUnary) KevalBothOperator(resource.binary, operator as KevalUnaryOperator) - else KevalBothOperator(operator as KevalBinaryOperator, resource.unary) + if (isUnary) KevalBothOperator(resource.binary, operator as KevalUnaryOperator) + else KevalBothOperator(operator as KevalBinaryOperator, resource.unary) else -> resources[symbol.toString()] = operator } } - companion object { - - val DEFAULT_RESOURCES: Map = mapOf( - // binary operators - "+" to KevalBothOperator( - KevalBinaryOperator(2, true) { a, b -> a + b }, - KevalUnaryOperator(true) { it } - ), - "-" to KevalBothOperator( - KevalBinaryOperator(2, true) { a, b -> a - b }, - KevalUnaryOperator(true) { -it } - ), - - "/" to KevalBinaryOperator(3, true) { a, b -> - if (b == 0.0) throw KevalZeroDivisionException() - a / b - }, - "%" to KevalBinaryOperator(3, true) { a, b -> - if (b == 0.0) throw KevalZeroDivisionException() - a % b - }, - "^" to KevalBinaryOperator(4, false) { a, b -> a.pow(b) }, - "*" to KevalBinaryOperator(3, true) { a, b -> a * b }, - - // unary operators - "!" to KevalUnaryOperator(false) { - if (it < 0) throw KevalInvalidArgumentException("factorial of a negative number") - if (floor(it) != it) throw KevalInvalidArgumentException("factorial of a non-integer") - var result = 1.0 - for (i in 2..it.toInt()) { - result *= i - } - result - }, - - // functions - "neg" to KevalFunction(1) { -it[0] }, - "sign" to KevalFunction(1) { if (it[0] < 0) -1.0 else if (it[0] > 0) 1.0 else 0.0 }, - "abs" to KevalFunction(1) { it[0].absoluteValue }, - "sqrt" to KevalFunction(1) { sqrt(it[0]) }, - "cbrt" to KevalFunction(1) { cbrt(it[0]) }, - "nthrt" to KevalFunction(2) { it[1].pow(1 / it[0]) }, - "exp" to KevalFunction(1) { exp(it[0]) }, - "ln" to KevalFunction(1) { ln(it[0]) }, - "log10" to KevalFunction(1) { log10(it[0]) }, - "log2" to KevalFunction(1) { log2(it[0]) }, - "sin" to KevalFunction(1) { sin(it[0]) }, - "cos" to KevalFunction(1) { cos(it[0]) }, - "tan" to KevalFunction(1) { tan(it[0]) }, - "asin" to KevalFunction(1) { asin(it[0]) }, - "acos" to KevalFunction(1) { acos(it[0]) }, - "atan" to KevalFunction(1) { atan(it[0]) }, - "ceil" to KevalFunction(1) { ceil(it[0]) }, - "floor" to KevalFunction(1) { floor(it[0]) }, - "round" to KevalFunction(1) { round(it[0]) }, - "trunc" to KevalFunction(1) { it[0].toInt().toDouble() }, - "min" to KevalFunction(null) { it.min() }, - "max" to KevalFunction(null) { it.max() }, - "sum" to KevalFunction(null) { it.sum() }, - "avg" to KevalFunction(null) { it.average() }, - "median" to KevalFunction(null) { it.sorted()[it.size / 2] }, - "percentile" to KevalFunction(null) { - if (it.size <= 1) throw KevalInvalidArgumentException("percentile requires at least 2 values") - val perc = it[0] - if (perc !in 0.0..100.0) throw KevalInvalidArgumentException("percentile must be between 0 and 100") - val sorted = it.sorted() - val index = ((perc / 100) * sorted.size).toInt() - sorted[index] - }, - "rand" to KevalFunction(null) { - when (it.size) { - 0 -> Random.Default.nextDouble() - 1 -> (0..it[0].toInt()).random().toDouble() - else -> it.random() - } - }, - "randRange" to KevalFunction(3) { - val start = it[0] - val end = it[1] - val step = it[2] - - if (step > 0) throw KevalInvalidArgumentException("step must be greater than 0") - val numberOfSteps = ((end - start) / step).toInt() - val randomStepIndex = Random.nextInt(0, numberOfSteps + 1) - start + randomStepIndex * step - }, - - // logical functions - "bool" to KevalFunction(1) { booleanToDouble(it[0] != 0.0) }, - "not" to KevalFunction(1) { booleanToDouble(!doubleToBoolean(it[0])) }, - "and" to KevalFunction(null) { it.reduceBoolean { a, b -> a && b } }, - "nand" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a && b } }, - "or" to KevalFunction(null) { it.reduceBoolean { a, b -> a || b } }, - "nor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a || b } }, - "xor" to KevalFunction(null) { it.reduceBoolean { a, b -> a xor b } }, - "xnor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a xor b } }, - "imply" to KevalFunction(2) { booleanOperation(it) { a, b -> !a || b } }, - "nimply" to KevalFunction(2) { booleanOperation(it) { a, b -> a && !b } }, - "eq" to KevalFunction(null) { booleanToDouble(it.all { e -> e == it[0] }) }, - "ne" to KevalFunction(null) { booleanToDouble(it.distinct().size == it.size) }, - "gt" to KevalFunction(2) { booleanToDouble(it[0] > it[1]) }, - "lt" to KevalFunction(2) { booleanToDouble(it[0] < it[1]) }, - "ge" to KevalFunction(2) { booleanToDouble(it[0] >= it[1]) }, - "le" to KevalFunction(2) { booleanToDouble(it[0] <= it[1]) }, - - // constants - "PI" to KevalConstant(PI), - "e" to KevalConstant(E) - ) - - private fun doubleToBoolean(value: Double) = value != 0.0 - private fun booleanToDouble(value: Boolean) = if (value) 1.0 else 0.0 - private fun booleanOperation(array: DoubleArray, operation: (Boolean, Boolean) -> Boolean) = - booleanToDouble(operation(doubleToBoolean(array[0]), doubleToBoolean(array[1]))) - private fun DoubleArray.viaBoolean(operation: List.() -> Boolean) = - booleanToDouble(operation(map(::doubleToBoolean))) - private fun DoubleArray.reduceBoolean(invert: Boolean = false, operation: (Boolean, Boolean) -> Boolean) = - viaBoolean { - reduce(operation).let { - if (invert) !it - else it - } - } - - /** - * Builder representation of a binary operator. - * - * @property symbol The symbol which represents the operator. - * @property precedence The precedence of the operator. - * @property isLeftAssociative True when the operator is left associative, false otherwise. - * @property implementation The actual implementation of the operator. - */ - data class BinaryOperatorBuilder( - var symbol: Char? = null, - var precedence: Int? = null, - var isLeftAssociative: Boolean? = null, - var implementation: ((Double, Double) -> Double)? = null - ) + /** + * Builder representation of a binary operator. + * + * @property symbol The symbol which represents the operator. + * @property precedence The precedence of the operator. + * @property isLeftAssociative True when the operator is left associative, false otherwise. + * @property implementation The actual implementation of the operator. + */ + data class BinaryOperatorBuilder( + var symbol: Char? = null, + var precedence: Int? = null, + var isLeftAssociative: Boolean? = null, + var implementation: ((N, N) -> N)? = null + ) - /** - * Builder representation of a unary operator. - * - * @property symbol The symbol which represents the operator. - * @property isPrefix True when the operator is prefix, false otherwise. - * @property implementation The actual implementation of the operator. - */ - data class UnaryOperatorBuilder( - var symbol: Char? = null, - var isPrefix: Boolean? = null, - var implementation: ((Double) -> Double)? = null - ) + /** + * Builder representation of a unary operator. + * + * @property symbol The symbol which represents the operator. + * @property isPrefix True when the operator is prefix, false otherwise. + * @property implementation The actual implementation of the operator. + */ + data class UnaryOperatorBuilder( + var symbol: Char? = null, + var isPrefix: Boolean? = null, + var implementation: ((N) -> N)? = null + ) - /** - * Builder representation of a function. - * - * @property name The identifier which represents the function. - * @property arity The arity of the function (how many arguments it takes). If null, the function is variadic - * @property implementation The actual implementation of the function. - */ - data class FunctionBuilder( - var name: String? = null, - var arity: Int? = null, - var implementation: ((DoubleArray) -> Double)? = null, - ) + /** + * Builder representation of a function. + * + * @property name The identifier which represents the function. + * @property arity The arity of the function (how many arguments it takes). If null, the function is variadic + * @property implementation The actual implementation of the function. + */ + data class FunctionBuilder( + var name: String? = null, + var arity: Int? = null, + var implementation: ((List) -> N)? = null, + ) - /** - * Builder representation of a constant. - * - * @property name The identifier which represents the constant. - * @property value The value of the constant. - */ - data class ConstantBuilder( - var name: String? = null, - var value: Double? = null - ) - } + /** + * Builder representation of a constant. + * + * @property name The identifier which represents the constant. + * @property value The value of the constant. + */ + data class ConstantBuilder( + var name: String? = null, + var value: N? = null + ) } diff --git a/src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt b/src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt new file mode 100644 index 0000000..9879c4f --- /dev/null +++ b/src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt @@ -0,0 +1,24 @@ +package com.notkamui.keval + +/** + * Describes how a numeric type is parsed and which built-in operators, functions, and constants + * are available by default for that type. + */ +interface KevalNumber { + fun isValidLiteral(token: String): Boolean + fun parseLiteral(token: String): N + fun defaultResources(): Map> +} + +/** + * Entry points for built-in numeric type implementations. + */ +object KevalNumbers { + val Double: KevalNumber = KevalNumberDouble +} + +/** + * Evaluates [expression] using this number type's default resources. + */ +fun KevalNumber.eval(expression: String): N = + Keval.create(this) { includeDefault() }.eval(expression) diff --git a/src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt b/src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt new file mode 100644 index 0000000..cae654d --- /dev/null +++ b/src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt @@ -0,0 +1,132 @@ +package com.notkamui.keval + +import kotlin.math.* +import kotlin.random.Random + +object KevalNumberDouble : KevalNumber { + override fun isValidLiteral(token: String): Boolean = token.toDoubleOrNull() != null + + override fun parseLiteral(token: String): Double = token.toDouble() + + override fun defaultResources(): Map> = mapOf( + // binary operators + "+" to KevalBothOperator( + KevalBinaryOperator(2, true) { a, b -> a + b }, + KevalUnaryOperator(true) { it } + ), + "-" to KevalBothOperator( + KevalBinaryOperator(2, true) { a, b -> a - b }, + KevalUnaryOperator(true) { -it } + ), + + "/" to KevalBinaryOperator(3, true) { a, b -> + if (b == 0.0) throw KevalZeroDivisionException() + a / b + }, + "%" to KevalBinaryOperator(3, true) { a, b -> + if (b == 0.0) throw KevalZeroDivisionException() + a % b + }, + "^" to KevalBinaryOperator(4, false) { a, b -> a.pow(b) }, + "*" to KevalBinaryOperator(3, true) { a, b -> a * b }, + + // unary operators + "!" to KevalUnaryOperator(false) { + if (it < 0) throw KevalInvalidArgumentException("factorial of a negative number") + if (floor(it) != it) throw KevalInvalidArgumentException("factorial of a non-integer") + var result = 1.0 + for (i in 2..it.toInt()) { + result *= i + } + result + }, + + // functions + "neg" to KevalFunction(1) { -it[0] }, + "sign" to KevalFunction(1) { if (it[0] < 0) -1.0 else if (it[0] > 0) 1.0 else 0.0 }, + "abs" to KevalFunction(1) { it[0].absoluteValue }, + "sqrt" to KevalFunction(1) { sqrt(it[0]) }, + "cbrt" to KevalFunction(1) { cbrt(it[0]) }, + "nthrt" to KevalFunction(2) { it[1].pow(1 / it[0]) }, + "exp" to KevalFunction(1) { exp(it[0]) }, + "ln" to KevalFunction(1) { ln(it[0]) }, + "log10" to KevalFunction(1) { log10(it[0]) }, + "log2" to KevalFunction(1) { log2(it[0]) }, + "sin" to KevalFunction(1) { sin(it[0]) }, + "cos" to KevalFunction(1) { cos(it[0]) }, + "tan" to KevalFunction(1) { tan(it[0]) }, + "asin" to KevalFunction(1) { asin(it[0]) }, + "acos" to KevalFunction(1) { acos(it[0]) }, + "atan" to KevalFunction(1) { atan(it[0]) }, + "ceil" to KevalFunction(1) { ceil(it[0]) }, + "floor" to KevalFunction(1) { floor(it[0]) }, + "round" to KevalFunction(1) { round(it[0]) }, + "trunc" to KevalFunction(1) { it[0].toInt().toDouble() }, + "min" to KevalFunction(null) { it.min() }, + "max" to KevalFunction(null) { it.max() }, + "sum" to KevalFunction(null) { it.sum() }, + "avg" to KevalFunction(null) { it.average() }, + "median" to KevalFunction(null) { it.sorted()[it.size / 2] }, + "percentile" to KevalFunction(null) { + if (it.size <= 1) throw KevalInvalidArgumentException("percentile requires at least 2 values") + val perc = it[0] + if (perc !in 0.0..100.0) throw KevalInvalidArgumentException("percentile must be between 0 and 100") + val sorted = it.sorted() + val index = ((perc / 100) * sorted.size).toInt() + sorted[index] + }, + "rand" to KevalFunction(null) { + when (it.size) { + 0 -> Random.Default.nextDouble() + 1 -> (0..it[0].toInt()).random().toDouble() + else -> it.random() + } + }, + "randRange" to KevalFunction(3) { + val start = it[0] + val end = it[1] + val step = it[2] + + if (step > 0) throw KevalInvalidArgumentException("step must be greater than 0") + val numberOfSteps = ((end - start) / step).toInt() + val randomStepIndex = Random.nextInt(0, numberOfSteps + 1) + start + randomStepIndex * step + }, + + // logical functions + "bool" to KevalFunction(1) { booleanToDouble(it[0] != 0.0) }, + "not" to KevalFunction(1) { booleanToDouble(!doubleToBoolean(it[0])) }, + "and" to KevalFunction(null) { it.reduceBoolean { a, b -> a && b } }, + "nand" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a && b } }, + "or" to KevalFunction(null) { it.reduceBoolean { a, b -> a || b } }, + "nor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a || b } }, + "xor" to KevalFunction(null) { it.reduceBoolean { a, b -> a xor b } }, + "xnor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a xor b } }, + "imply" to KevalFunction(2) { booleanOperation(it) { a, b -> !a || b } }, + "nimply" to KevalFunction(2) { booleanOperation(it) { a, b -> a && !b } }, + "eq" to KevalFunction(null) { booleanToDouble(it.all { e -> e == it[0] }) }, + "ne" to KevalFunction(null) { booleanToDouble(it.distinct().size == it.size) }, + "gt" to KevalFunction(2) { booleanToDouble(it[0] > it[1]) }, + "lt" to KevalFunction(2) { booleanToDouble(it[0] < it[1]) }, + "ge" to KevalFunction(2) { booleanToDouble(it[0] >= it[1]) }, + "le" to KevalFunction(2) { booleanToDouble(it[0] <= it[1]) }, + + // constants + "PI" to KevalConstant(PI), + "e" to KevalConstant(E) + ) + + private fun doubleToBoolean(value: Double) = value != 0.0 + private fun booleanToDouble(value: Boolean) = if (value) 1.0 else 0.0 + private fun booleanOperation(array: List, operation: (Boolean, Boolean) -> Boolean) = + booleanToDouble(operation(doubleToBoolean(array[0]), doubleToBoolean(array[1]))) + private fun List.viaBoolean(operation: List.() -> Boolean) = + booleanToDouble(operation(map(::doubleToBoolean))) + private fun List.reduceBoolean(invert: Boolean = false, operation: (Boolean, Boolean) -> Boolean) = + viaBoolean { + reduce(operation).let { + if (invert) !it + else it + } + } +} diff --git a/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt b/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt index fd25330..55c661e 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt @@ -8,7 +8,10 @@ private fun shouldAssumeMul(tokenType: TokenType): Boolean = tokenType == TokenType.OPERAND || tokenType == TokenType.RPAREN // normalize tokens to be of specific form (add product symbols where they should be assumed) -private fun Sequence.normalizeTokens(symbols: Map): List { +private fun Sequence.normalizeTokens( + number: KevalNumber, + symbols: Map>, +): List { var currentPos = 0 var prevToken = TokenType.FIRST var parenthesesCount = 0 @@ -16,7 +19,7 @@ private fun Sequence.normalizeTokens(symbols: Map val ret = mutableListOf() this.forEach { token -> prevToken = when { - token.isNumeric() || symbols[token] is KevalConstant -> TokenType.OPERAND.also { + token.isNumeric(number) || symbols[token] is KevalConstant -> TokenType.OPERAND.also { if (shouldAssumeMul(prevToken)) ret.add("*") ret.add(token) } @@ -68,10 +71,8 @@ private fun Sequence.normalizeTokens(symbols: Map * @receiver is the string to check * @return true if the string is numeric, false otherwise */ -internal fun String.isNumeric(): Boolean { - toDoubleOrNull() ?: return false - return true -} +internal fun String.isNumeric(number: KevalNumber): Boolean = + number.isValidLiteral(this) /** * Checks if a string is a Keval Operator or not @@ -88,13 +89,17 @@ internal fun String.isKevalOperator(symbolsSet: Set): Boolean = this in * @return the list of tokens * @throws KevalInvalidSymbolException if the expression contains an invalid symbol */ -internal fun String.tokenize(symbolsSet: Map): List = +internal fun String.tokenize( + number: KevalNumber, + symbolsSet: Map>, +): List = TOKENIZER_REGEX.findAll(this) .map(MatchResult::value) .filter(String::isNotBlank) .map { SANITIZE_REGEX.replace(it, "") } - .normalizeTokens(symbolsSet) + .normalizeTokens(number, symbolsSet) private val SANITIZE_REGEX = """\s+""".toRegex() -private val TOKENIZER_REGEX = """(\d+\.\d+|\d+|[a-zA-Z_]\w*|[^\w\s])""".toRegex() +private val TOKENIZER_REGEX = + """(\d+\.\d+(?:[eE][+-]?\d+)?|\d+(?:[eE][+-]?\d+)?|[a-zA-Z_]\w*|[^\w\s])""".toRegex() diff --git a/src/commonTest/kotlin/com/notkamui/keval/ASTTest.kt b/src/commonTest/kotlin/com/notkamui/keval/ASTTest.kt index 3bb70e3..3789157 100644 --- a/src/commonTest/kotlin/com/notkamui/keval/ASTTest.kt +++ b/src/commonTest/kotlin/com/notkamui/keval/ASTTest.kt @@ -12,9 +12,9 @@ class ASTTest { */ @Test fun simpleEvalTest() { - val operators = KevalBuilder.DEFAULT_RESOURCES - val plus = (operators["+"] as? KevalBothOperator)!!.binary.implementation - val ast: Node = BinaryOperatorNode(ValueNode(3.0), plus, ValueNode(2.0)) + val operators = KevalNumbers.Double.defaultResources() + val plus = (operators["+"] as? KevalBothOperator)!!.binary.implementation + val ast: Node = BinaryOperatorNode(ValueNode(3.0), plus, ValueNode(2.0)) assertEquals(ast.eval(), 5.0) } diff --git a/src/commonTest/kotlin/com/notkamui/keval/DSLResourcesTests.kt b/src/commonTest/kotlin/com/notkamui/keval/DSLResourcesTests.kt index ac90513..ee024d3 100644 --- a/src/commonTest/kotlin/com/notkamui/keval/DSLResourcesTests.kt +++ b/src/commonTest/kotlin/com/notkamui/keval/DSLResourcesTests.kt @@ -12,7 +12,7 @@ fun hypotenuse(x: Double, y: Double): Double = sqrt(x*x + y*y) class DLSTest { @Test fun checkSimpleDLS() { - val kvl = Keval.create { + val kvl = Keval.create(KevalNumbers.Double) { binaryOperator { symbol = ';' implementation = ::hypotenuse @@ -42,7 +42,7 @@ class DLSTest { @Test fun checkCombinedDSL() { - val kvl = Keval.create { + val kvl = Keval.create(KevalNumbers.Double) { includeDefault() binaryOperator { symbol = ';' @@ -84,7 +84,7 @@ class DLSTest { @Test fun conflictTests() { - val kvl = Keval.create { + val kvl = Keval.create(KevalNumbers.Double) { function { name = "a" arity = 1 @@ -106,7 +106,7 @@ class DLSTest { @Test fun checkWith() { - val kvl = Keval.create() + val kvl = Keval.create(KevalNumbers.Double) .withDefault() .withBinaryOperator( ';', @@ -152,7 +152,7 @@ class DLSTest { @Test fun checkOrder() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.Double) { includeDefault() function { name = "first" @@ -183,7 +183,7 @@ class DLSTest { @Test fun checkCoherence() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.Double) { includeDefault() function { name = "if" @@ -198,7 +198,7 @@ class DLSTest { @Test fun checkRepeatingParentheses() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.Double) { includeDefault() function { name = "f" @@ -211,7 +211,7 @@ class DLSTest { @Test fun checkFlexibleArity() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.Double) { includeDefault() function { name = "sum" @@ -223,7 +223,7 @@ class DLSTest { @Test fun checkFlexibleArityWithZeroArgs() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.Double) { includeDefault() function { name = "sum" @@ -236,7 +236,7 @@ class DLSTest { @Test fun checkOverrideAnOperatorShouldNotFail() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.Double) { includeDefault() binaryOperator { symbol = '+' @@ -251,7 +251,7 @@ class DLSTest { // this test fails due to wrong handling of nested calls @Test fun checkLogicalOperations() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.Double) { includeDefault() function { name = "isPositive" diff --git a/src/commonTest/kotlin/com/notkamui/keval/GrammarTest.kt b/src/commonTest/kotlin/com/notkamui/keval/GrammarTest.kt index c6b44e4..f36d4e3 100644 --- a/src/commonTest/kotlin/com/notkamui/keval/GrammarTest.kt +++ b/src/commonTest/kotlin/com/notkamui/keval/GrammarTest.kt @@ -14,8 +14,8 @@ class GrammarTest { */ @Test fun grammarTest() { - val operators = KevalBuilder.DEFAULT_RESOURCES - assertEquals(8.0, "3 + 5 * (2-1)".toAST(operators).eval()) + val operators = KevalNumbers.Double.defaultResources() + assertEquals(8.0, "3 + 5 * (2-1)".toAST(KevalNumbers.Double, operators).eval()) } /** diff --git a/src/commonTest/kotlin/com/notkamui/keval/TokenizerTest.kt b/src/commonTest/kotlin/com/notkamui/keval/TokenizerTest.kt index d92c930..6eb2abc 100644 --- a/src/commonTest/kotlin/com/notkamui/keval/TokenizerTest.kt +++ b/src/commonTest/kotlin/com/notkamui/keval/TokenizerTest.kt @@ -14,7 +14,7 @@ class TokenizerTest { */ @Test fun parseString() { - val operators = KevalBuilder.DEFAULT_RESOURCES.plus( + val operators = KevalNumbers.Double.defaultResources().plus( listOf( "A_1a2b3c" to KevalConstant(1.2), "A_a1b2c3" to KevalConstant(2.3), @@ -25,7 +25,7 @@ class TokenizerTest { "__A" to KevalConstant(7.8), ) ) - val tokens = "((34+8)/3)+3.3*(5+2)%2^6+A_1a2b3c^4.2+A_a1b2c3/4.2-A1_1A_A1_AB_AB_12%4.2+A__B-A__+_A-__A".tokenize(operators) + val tokens = "((34+8)/3)+3.3*(5+2)%2^6+A_1a2b3c^4.2+A_a1b2c3/4.2-A1_1A_A1_AB_AB_12%4.2+A__B-A__+_A-__A".tokenize(KevalNumbers.Double, operators) assertEquals( listOf( "(", @@ -73,7 +73,7 @@ class TokenizerTest { tokens ) - val tokens2 = "(3+4 ) (2-5) ".tokenize(operators) // check auto mul + val tokens2 = "(3+4 ) (2-5) ".tokenize(KevalNumbers.Double, operators) // check auto mul assertEquals( listOf("(", "3", "+", "4", ")", "*", "(", "2", "-", "5", ")"), tokens2 @@ -81,7 +81,7 @@ class TokenizerTest { assertTrue { try { - "(37+4)a+5".tokenize(operators) + "(37+4)a+5".tokenize(KevalNumbers.Double, operators) false } catch (e: KevalInvalidSymbolException) { e.invalidSymbol == "a" && e.position == 6 && e.expression == "(37+4)a+5" @@ -91,7 +91,7 @@ class TokenizerTest { @Test fun checkRepeatingParentheses() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.Double) { includeDefault() function { name = "f" @@ -100,13 +100,13 @@ class TokenizerTest { } } - val nodes = "f(((1)))".tokenize(k.resourcesView()) + val nodes = "f(((1)))".tokenize(KevalNumbers.Double, k.resourcesView()) assertEquals("f(((1)))", nodes.joinToString(separator = "")) } @Test fun checkNestedFunctions() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.Double) { includeDefault() function { name = "f" @@ -124,7 +124,7 @@ class TokenizerTest { } } - val nodes = "f(s(a(1,2),3))".tokenize(k.resourcesView()) + val nodes = "f(s(a(1,2),3))".tokenize(KevalNumbers.Double, k.resourcesView()) assertEquals("f(s(a(1,2),3))", nodes.joinToString(separator = "")) } } diff --git a/src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt b/src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt new file mode 100644 index 0000000..a153f9e --- /dev/null +++ b/src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt @@ -0,0 +1,117 @@ +package com.notkamui.keval + +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode + +object KevalNumberBigDecimal : KevalNumber { + val mathContext: MathContext = MathContext.DECIMAL128 + + private val ZERO = BigDecimal.ZERO + private val ONE = BigDecimal.ONE + private val NEG_ONE = BigDecimal.valueOf(-1) + + override fun isValidLiteral(token: String): Boolean = try { + BigDecimal(token) + true + } catch (_: NumberFormatException) { + false + } + + override fun parseLiteral(token: String): BigDecimal = BigDecimal(token) + + override fun defaultResources(): Map> = mapOf( + "+" to KevalBothOperator( + KevalBinaryOperator(2, true) { a, b -> a.add(b) }, + KevalUnaryOperator(true) { it } + ), + "-" to KevalBothOperator( + KevalBinaryOperator(2, true) { a, b -> a.subtract(b) }, + KevalUnaryOperator(true) { it.negate() } + ), + "/" to KevalBinaryOperator(3, true) { a, b -> + if (b.compareTo(ZERO) == 0) throw KevalZeroDivisionException() + a.divide(b, mathContext) + }, + "%" to KevalBinaryOperator(3, true) { a, b -> + if (b.compareTo(ZERO) == 0) throw KevalZeroDivisionException() + a.remainder(b) + }, + "^" to KevalBinaryOperator(4, false) { a, b -> + if (b.stripTrailingZeros().scale() > 0) { + throw KevalInvalidArgumentException("non-integer exponent") + } + val exp = b.intValueExact() + when { + exp >= 0 -> a.pow(exp) + a.compareTo(ZERO) == 0 -> throw KevalInvalidArgumentException("zero to a negative power") + else -> ONE.divide(a.pow(-exp), mathContext) + } + }, + "*" to KevalBinaryOperator(3, true) { a, b -> a.multiply(b) }, + + "neg" to KevalFunction(1) { it[0].negate() }, + "abs" to KevalFunction(1) { it[0].abs() }, + "sign" to KevalFunction(1) { + when (it[0].compareTo(ZERO)) { + -1 -> NEG_ONE + 1 -> ONE + else -> ZERO + } + }, + "min" to KevalFunction(null) { args -> args.minWithOrNull(compareBy { it })!! }, + "max" to KevalFunction(null) { args -> args.maxWithOrNull(compareBy { it })!! }, + "sum" to KevalFunction(null) { args -> args.fold(ZERO, BigDecimal::add) }, + "avg" to KevalFunction(null) { args -> + args.fold(ZERO, BigDecimal::add) + .divide(BigDecimal.valueOf(args.size.toLong()), mathContext) + }, + "ceil" to KevalFunction(1) { it[0].setScale(0, RoundingMode.CEILING) }, + "floor" to KevalFunction(1) { it[0].setScale(0, RoundingMode.FLOOR) }, + "round" to KevalFunction(1) { it[0].setScale(0, RoundingMode.HALF_UP) }, + "trunc" to KevalFunction(1) { it[0].setScale(0, RoundingMode.DOWN) }, + + "bool" to KevalFunction(1) { booleanToDecimal(isTruthy(it[0])) }, + "not" to KevalFunction(1) { booleanToDecimal(!isTruthy(it[0])) }, + "and" to KevalFunction(null) { it.reduceBoolean { a, b -> a && b } }, + "nand" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a && b } }, + "or" to KevalFunction(null) { it.reduceBoolean { a, b -> a || b } }, + "nor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a || b } }, + "xor" to KevalFunction(null) { it.reduceBoolean { a, b -> a xor b } }, + "xnor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a xor b } }, + "imply" to KevalFunction(2) { booleanOperation(it) { a, b -> !a || b } }, + "nimply" to KevalFunction(2) { booleanOperation(it) { a, b -> a && !b } }, + "eq" to KevalFunction(null) { booleanToDecimal(it.all { e -> e.compareTo(it[0]) == 0 }) }, + "ne" to KevalFunction(null) { + booleanToDecimal(it.map { e -> e.stripTrailingZeros() }.distinct().size == it.size) + }, + "gt" to KevalFunction(2) { booleanToDecimal(it[0].compareTo(it[1]) > 0) }, + "lt" to KevalFunction(2) { booleanToDecimal(it[0].compareTo(it[1]) < 0) }, + "ge" to KevalFunction(2) { booleanToDecimal(it[0].compareTo(it[1]) >= 0) }, + "le" to KevalFunction(2) { booleanToDecimal(it[0].compareTo(it[1]) <= 0) }, + ) + + private fun isTruthy(value: BigDecimal) = value.compareTo(ZERO) != 0 + private fun booleanToDecimal(value: Boolean) = if (value) ONE else ZERO + private fun booleanOperation(array: List, operation: (Boolean, Boolean) -> Boolean) = + booleanToDecimal(operation(isTruthy(array[0]), isTruthy(array[1]))) + private fun List.viaBoolean(operation: List.() -> Boolean) = + booleanToDecimal(operation(map(::isTruthy))) + private fun List.reduceBoolean(invert: Boolean = false, operation: (Boolean, Boolean) -> Boolean) = + viaBoolean { + reduce(operation).let { + if (invert) !it + else it + } + } +} + +val KevalNumbers.BigDecimal: KevalNumber + get() = KevalNumberBigDecimal + +/** + * Evaluates a mathematical expression using default BigDecimal resources. + */ +fun String.kevalBigDecimal( + generator: KevalBuilder.() -> Unit = { includeDefault() } +): BigDecimal = Keval.create(KevalNumberBigDecimal, generator).eval(this) diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalArithmeticTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalArithmeticTest.kt new file mode 100644 index 0000000..2b12cad --- /dev/null +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalArithmeticTest.kt @@ -0,0 +1,136 @@ +package com.notkamui.keval + +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class KevalBigDecimalArithmeticTest { + + @Test + fun addition() { + assertDecimalEquals("10", eval("3 + 7")) + assertDecimalEquals("0.3", eval("0.1 + 0.2")) + } + + @Test + fun subtraction() { + assertDecimalEquals("4", eval("10 - 6")) + assertDecimalEquals("-3", eval("2 - 5")) + } + + @Test + fun multiplication() { + assertDecimalEquals("42", eval("6 * 7")) + assertDecimalEquals("0.06", eval("0.2 * 0.3")) + } + + @Test + fun division() { + assertDecimalEquals("2.5", eval("5 / 2")) + assertDecimalEquals("0.3333333333333333333333333333333333", eval("1 / 3")) + } + + @Test + fun modulo() { + assertDecimalEquals("1", eval("10 % 3")) + assertDecimalEquals("0", eval("9 % 3")) + } + + @Test + fun unaryPlusAndMinus() { + assertDecimalEquals("5", eval("+5")) + assertDecimalEquals("-5", eval("-5")) + assertDecimalEquals("3", eval("5 + -2")) + } + + @Test + fun integerPower() { + assertDecimalEquals("8", eval("2 ^ 3")) + assertDecimalEquals("1", eval("5 ^ 0")) + assertDecimalEquals("1024", eval("2 ^ 10")) + } + + @Test + fun powerRightAssociativity() { + assertDecimalEquals("512", eval("2 ^ 3 ^ 2")) + } + + @Test + fun powerNegativeIntegerExponent() { + assertDecimalEquals("0.25", eval("2 ^ -2")) + assertDecimalEquals("0.5", eval("2 ^ -1")) + } + + @Test + fun zeroToNegativePowerThrows() { + assertFailsWith { eval("0 ^ -1") } + } + + @Test + fun operatorPrecedence() { + assertDecimalEquals("8", eval("3 + 5 * (2 - 1)")) + assertDecimalEquals("8", eval("(3 + 5) * (2 - 1)")) + assertDecimalEquals("7", eval("1 + 2 * 3")) + } + + @Test + fun implicitMultiplication() { + assertDecimalEquals("50", eval("(2 + 3)(4 + 6)")) + assertDecimalEquals("12", eval("3(2 + 2)")) + } + + @Test + fun implicitMultiplicationBetweenLiterals() { + assertDecimalEquals("2", eval("1 2")) + } + + @Test + fun nestedParentheses() { + assertDecimalEquals("1", eval("((((1))))")) + } + + @Test + fun largePrecisionLiterals() { + assertDecimalEquals( + "123456789012345678901234567890.123456789", + eval("123456789012345678901234567890.123456789") + ) + } + + @Test + fun negativeLiteralAndExpression() { + assertDecimalEquals("-15", eval("-3 * 5")) + assertDecimalEquals("15", eval("-3 * -5")) + } + + @Test + fun divisionByZeroThrows() { + assertFailsWith { eval("1 / 0") } + assertFailsWith { eval("0 / 0") } + } + + @Test + fun moduloByZeroThrows() { + assertFailsWith { eval("1 % 0") } + } + + @Test + fun nonIntegerPowerThrows() { + assertFailsWith { eval("2 ^ 0.5") } + assertFailsWith { eval("2 ^ 1.5") } + } + + @Test + fun negFunction() { + assertDecimalEquals("-7", eval("neg(7)")) + assertDecimalEquals("7", eval("neg(-7)")) + } + + @Test + fun complexExpression() { + assertDecimalEquals("14.5", eval("(sum(1, 2, 3) * 2) + (10 / 4)")) + } + + private fun eval(expr: String): BigDecimal = expr.evalDecimal() +} diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt new file mode 100644 index 0000000..984662e --- /dev/null +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt @@ -0,0 +1,137 @@ +package com.notkamui.keval + +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class KevalBigDecimalBuilderTest { + + @Test + fun kevalNumbersBigDecimalEntryPoint() { + assertEquals(KevalNumberBigDecimal, KevalNumbers.BigDecimal) + } + + @Test + fun kevalNumberEvalExtension() { + assertDecimalEquals("4", KevalNumbers.BigDecimal.eval("2 + 2")) + } + + @Test + fun createWithoutDefaultsRequiresCustomOps() { + assertFailsWith { + Keval.create(KevalNumbers.BigDecimal) {}.eval("1 + 1") + } + } + + @Test + fun customBinaryOperator() { + val kvl = Keval.create(KevalNumbers.BigDecimal) { + includeDefault() + binaryOperator { + symbol = ';' + precedence = 3 + isLeftAssociative = true + implementation = { a, b -> a.multiply(a).add(b.multiply(b)) } + } + } + assertDecimalEquals("25", kvl.eval("3;4")) + } + + @Test + fun customUnaryOperator() { + val kvl = Keval.create(KevalNumbers.BigDecimal) { + includeDefault() + unaryOperator { + symbol = '&' + isPrefix = true + implementation = { it.negate() } + } + } + assertEquals(BigDecimal("-5"), kvl.eval("&5")) + } + + @Test + fun customFunction() { + val kvl = Keval.create(KevalNumbers.BigDecimal) { + includeDefault() + function { + name = "double" + arity = 1 + implementation = { it[0].multiply(BigDecimal.TWO) } + } + } + assertEquals(BigDecimal("42"), kvl.eval("double(21)")) + } + + @Test + fun customConstant() { + val kvl = Keval.create(KevalNumbers.BigDecimal) { + includeDefault() + constant { + name = "TEN" + value = BigDecimal.TEN + } + } + assertDecimalEquals("15", kvl.eval("TEN + 5")) + } + + @Test + fun withMethodsChain() { + val kvl = Keval.create(KevalNumbers.BigDecimal) { includeDefault() } + .withConstant("PHI", BigDecimal("1.618")) + .withFunction("twice", 1) { it[0].multiply(BigDecimal.TWO) } + + assertDecimalEquals("3.236", kvl.eval("twice(PHI)")) + } + + @Test + fun kevalBigDecimalWithGenerator() { + assertDecimalEquals("99", "x + 1".kevalBigDecimal { + includeDefault() + constant { + name = "x" + value = BigDecimal("98") + } + }) + } + + @Test + fun excludedDoubleOnlyFunctionsNotInDefaults() { + val defaults = KevalNumberBigDecimal.defaultResources() + listOf("sin", "cos", "tan", "sqrt", "ln", "exp", "rand", "!", "PI", "e", "median", "percentile") + .forEach { name -> + assertFalse(name in defaults, "$name should not be in BigDecimal defaults") + } + } + + @Test + fun includedFunctionsAreInDefaults() { + val defaults = KevalNumberBigDecimal.defaultResources() + listOf( + "+", "-", "*", "/", "%", "^", + "neg", "abs", "sign", "min", "max", "sum", "avg", + "ceil", "floor", "round", "trunc", + "bool", "not", "and", "or", "eq", "ne", "gt", "lt", "ge", "le", + "nand", "nor", "xor", "xnor", "imply", "nimply", + ).forEach { name -> + assertTrue(name in defaults, "$name should be in BigDecimal defaults") + } + } + + @Test + fun resourcesViewAlwaysIncludesMultiply() { + val kvl = Keval.create(KevalNumbers.BigDecimal) { + binaryOperator { + symbol = '+' + precedence = 1 + isLeftAssociative = true + implementation = { a, b -> a.add(b) } + } + } + assertTrue("*" in kvl.resourcesView()) + assertEquals(BigDecimal("6"), kvl.eval("2*3")) + } +} diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalErrorsTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalErrorsTest.kt new file mode 100644 index 0000000..61350d0 --- /dev/null +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalErrorsTest.kt @@ -0,0 +1,48 @@ +package com.notkamui.keval + +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class KevalBigDecimalErrorsTest { + + @Test + fun emptyExpressionThrows() { + assertFailsWith { "".kevalBigDecimal() } + assertFailsWith { " ".kevalBigDecimal() } + assertFailsWith { "()".kevalBigDecimal() } + } + + @Test + fun mismatchedParenthesesThrow() { + assertFailsWith { "(1 + 2".kevalBigDecimal() } + assertFailsWith { "1 + 2)".kevalBigDecimal() } + assertFailsWith { "(3+1)) - 2".kevalBigDecimal() } + } + + @Test + fun invalidSymbolThrows() { + assertFailsWith { "1 + a".kevalBigDecimal() } + } + + @Test + fun unexpectedTokenThrows() { + assertFailsWith { "1 +".kevalBigDecimal() } + assertFailsWith { "(1 +".kevalBigDecimal() } + } + + @Test + fun unknownFunctionThrows() { + assertFailsWith { "sin(1)".kevalBigDecimal() } + } + + @Test + fun wrongFunctionArityThrows() { + assertFailsWith { "abs(1, 2)".kevalBigDecimal() } + assertFailsWith { "gt(1)".kevalBigDecimal() } + } + + @Test + fun invalidCommaOutsideFunctionThrows() { + assertFailsWith { "1, 2".kevalBigDecimal() } + } +} diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalFunctionsTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalFunctionsTest.kt new file mode 100644 index 0000000..27a49ea --- /dev/null +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalFunctionsTest.kt @@ -0,0 +1,85 @@ +package com.notkamui.keval + +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals + +class KevalBigDecimalFunctionsTest { + + @Test + fun abs() { + assertDecimalEquals("7", eval("abs(-7)")) + assertDecimalEquals("7", eval("abs(7)")) + assertDecimalEquals("0", eval("abs(0)")) + } + + @Test + fun sign() { + assertDecimalEquals("-1", eval("sign(-42)")) + assertDecimalEquals("0", eval("sign(0)")) + assertDecimalEquals("1", eval("sign(42)")) + } + + @Test + fun min() { + assertDecimalEquals("1", eval("min(3, 1, 2)")) + assertDecimalEquals("-5", eval("min(-5, 0, 5)")) + assertDecimalEquals("7", eval("min(7)")) + } + + @Test + fun max() { + assertDecimalEquals("9", eval("max(3, 9, 2)")) + assertDecimalEquals("5", eval("max(-5, 0, 5)")) + } + + @Test + fun sum() { + assertDecimalEquals("6", eval("sum(1, 2, 3)")) + assertDecimalEquals("0", eval("sum()")) + assertDecimalEquals("100", eval("sum(100)")) + } + + @Test + fun avg() { + assertDecimalEquals("2", eval("avg(1, 2, 3)")) + assertDecimalEquals("5", eval("avg(5)")) + assertDecimalEquals("2.5", eval("avg(1, 2, 3, 4)")) + } + + @Test + fun ceil() { + assertDecimalEquals("3", eval("ceil(2.1)")) + assertDecimalEquals("-2", eval("ceil(-2.9)")) + assertDecimalEquals("3", eval("ceil(2.9)")) + } + + @Test + fun floor() { + assertDecimalEquals("2", eval("floor(2.9)")) + assertDecimalEquals("-3", eval("floor(-2.1)")) + } + + @Test + fun round() { + assertDecimalEquals("3", eval("round(2.5)")) + assertDecimalEquals("2", eval("round(2.4)")) + assertDecimalEquals("-2", eval("round(-2.4)")) + assertDecimalEquals("-3", eval("round(-2.5)")) + } + + @Test + fun trunc() { + assertDecimalEquals("2", eval("trunc(2.9)")) + assertDecimalEquals("-2", eval("trunc(-2.9)")) + assertDecimalEquals("0", eval("trunc(0.99)")) + } + + @Test + fun nestedFunctions() { + assertDecimalEquals("6", eval("sum(abs(-1), abs(-2), abs(-3))")) + assertDecimalEquals("3", eval("max(min(1, 5), min(3, 9))")) + } + + private fun eval(expr: String): BigDecimal = expr.evalDecimal() +} diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalLogicalTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalLogicalTest.kt new file mode 100644 index 0000000..01980b0 --- /dev/null +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalLogicalTest.kt @@ -0,0 +1,111 @@ +package com.notkamui.keval + +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals + +class KevalBigDecimalLogicalTest { + + private fun eval(expr: String): BigDecimal = expr.kevalBigDecimal() + private val t = BigDecimal.ONE + private val f = BigDecimal.ZERO + + @Test + fun boolAndNot() { + assertEquals(t, eval("bool(1)")) + assertEquals(f, eval("bool(0)")) + assertEquals(t, eval("not(0)")) + assertEquals(f, eval("not(1)")) + } + + @Test + fun truthinessUsesZeroComparison() { + assertEquals(t, eval("bool(0.0001)")) + assertEquals(f, eval("bool(0.0)")) + } + + @Test + fun eq() { + assertEquals(t, eval("eq(1, 1, 1)")) + assertEquals(f, eval("eq(1, 2)")) + assertEquals(t, eval("eq(1.0, 1.00)")) + } + + @Test + fun ne() { + assertEquals(t, eval("ne(1, 2, 3)")) + assertEquals(f, eval("ne(1, 1)")) + assertEquals(f, eval("ne(1.0, 1.00)")) + } + + @Test + fun comparisons() { + assertEquals(t, eval("gt(3, 2)")) + assertEquals(f, eval("gt(2, 3)")) + assertEquals(t, eval("lt(1, 2)")) + assertEquals(t, eval("ge(5, 5)")) + assertEquals(t, eval("ge(6, 5)")) + assertEquals(f, eval("ge(4, 5)")) + assertEquals(t, eval("le(5, 5)")) + assertEquals(t, eval("le(4, 5)")) + assertEquals(f, eval("le(6, 5)")) + } + + @Test + fun andOr() { + assertEquals(t, eval("and(1, 1, 1)")) + assertEquals(f, eval("and(1, 0, 1)")) + assertEquals(t, eval("or(0, 0, 1)")) + assertEquals(f, eval("or(0, 0, 0)")) + } + + @Test + fun nandNor() { + assertEquals(f, eval("nand(1, 1)")) + assertEquals(t, eval("nand(1, 0)")) + assertEquals(f, eval("nor(1, 0)")) + assertEquals(t, eval("nor(0, 0)")) + } + + @Test + fun xorXnor() { + assertEquals(f, eval("xor(1, 1)")) + assertEquals(t, eval("xor(1, 0)")) + assertEquals(t, eval("xnor(1, 1)")) + assertEquals(f, eval("xnor(1, 0)")) + } + + @Test + fun implyNimply() { + assertEquals(t, eval("imply(0, 0)")) + assertEquals(f, eval("imply(1, 0)")) + assertEquals(t, eval("nimply(1, 0)")) + assertEquals(f, eval("nimply(0, 1)")) + } + + @Test + fun comprehensiveLogicalExpression() { + val expr = """ + and( + not(lt(5, 3)), + or( + gt(4, 2), + xor( + eq(1, 1, 1), + ne(1, 2, 3) + ) + ), + nand( + ge(5, 5), + not(le(3, 4)) + ), + nor( + imply(1, 0), + nimply(1, 1) + ), + xnor(1, 1) + ) + """.trimIndent() + assertEquals(t, eval(expr)) + } +} diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalParsingTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalParsingTest.kt new file mode 100644 index 0000000..9215b2a --- /dev/null +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalParsingTest.kt @@ -0,0 +1,72 @@ +package com.notkamui.keval + +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class KevalBigDecimalParsingTest { + + @Test + fun scientificNotationIntegerExponent() { + assertDecimalEquals("10000000000", "1e10".evalDecimal()) + assertDecimalEquals("1000", "1e3".evalDecimal()) + } + + @Test + fun scientificNotationDecimal() { + assertDecimalEquals("0.0015", "1.5e-3".evalDecimal()) + assertDecimalEquals("1500", "1.5e3".evalDecimal()) + } + + @Test + fun scientificNotationWithExplicitSign() { + assertDecimalEquals("100", "1e+2".evalDecimal()) + assertDecimalEquals("0.01", "1e-2".evalDecimal()) + } + + @Test + fun scientificNotationInExpression() { + assertDecimalEquals("20000000000", "1e10 + 1e10".evalDecimal()) + assertDecimalEquals("2", "1e0 + 1".evalDecimal()) + } + + @Test + fun isValidLiteralAcceptsNumericForms() { + assertTrue(KevalNumberBigDecimal.isValidLiteral("42")) + assertTrue(KevalNumberBigDecimal.isValidLiteral("3.14")) + assertTrue(KevalNumberBigDecimal.isValidLiteral("1e10")) + assertTrue(KevalNumberBigDecimal.isValidLiteral("-2.5")) + } + + @Test + fun isValidLiteralRejectsNonNumeric() { + assertFalse(KevalNumberBigDecimal.isValidLiteral("abc")) + assertFalse(KevalNumberBigDecimal.isValidLiteral("")) + assertFalse(KevalNumberBigDecimal.isValidLiteral("1..2")) + } + + @Test + fun parseLiteralMatchesBigDecimalConstructor() { + assertEquals(0, BigDecimal("-123.456").compareTo(KevalNumberBigDecimal.parseLiteral("-123.456"))) + } + + @Test + fun tokenizePreservesScientificLiteral() { + val tokens = "1e10 + 2e-3".tokenize( + KevalNumberBigDecimal, + KevalNumberBigDecimal.defaultResources() + ) + assertEquals(listOf("1e10", "+", "2e-3"), tokens) + } + + @Test + fun tokenizeImplicitMultiplication() { + val tokens = "(2+3)(4+1)".tokenize( + KevalNumberBigDecimal, + KevalNumberBigDecimal.defaultResources() + ) + assertEquals(listOf("(", "2", "+", "3", ")", "*", "(", "4", "+", "1", ")"), tokens) + } +} diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalTestSupport.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalTestSupport.kt new file mode 100644 index 0000000..1542c3a --- /dev/null +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalTestSupport.kt @@ -0,0 +1,14 @@ +package com.notkamui.keval + +import java.math.BigDecimal +import kotlin.test.assertEquals + +internal fun assertDecimalEquals(expected: String, actual: BigDecimal) { + assertEquals( + 0, + BigDecimal(expected).compareTo(actual), + "expected $expected but was $actual" + ) +} + +internal fun String.evalDecimal(): BigDecimal = this.kevalBigDecimal() From 9843c55219e858bda4180c84833adb024f6b1da6 Mon Sep 17 00:00:00 2001 From: notKamui Date: Sat, 23 May 2026 18:04:28 +0200 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0923456..8b84720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## [2.0.0] + +### Added + +- Generic numeric type support via [`KevalNumber`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt): parsing rules and default resources are defined per number type. +- [`KevalNumbers.Double`](src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt) — full default operator/function/constant set (same behaviour as v1.x). +- [`KevalNumbers.BigDecimal`](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) — JVM-only built-in for `java.math.BigDecimal` with arithmetic, comparison, aggregates, and rounding defaults (no trig/log/random). +- [`String.kevalBigDecimal()`](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) and `KevalNumber.eval(String)` convenience entry points. +- Scientific notation in numeric literals (e.g. `1e10`, `1.5e-3`). +- Negative integer exponents for BigDecimal `^` (e.g. `2 ^ -2` → `0.25`). +- Extensive JVM test suite for BigDecimal evaluation, parsing, builder API, and error cases. + +### Changed + +#### Non-breaking + +- `String.keval()` and `Keval.eval(String)` remain `Double`-only shortcuts with the same ergonomics as v1.x. +- Android, JS, and Native targets continue to use the `Double` API from `commonMain` without an explicit Android publication target. + +#### Breaking + +- `Keval` is now `Keval` and requires a [`KevalNumber`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt) context. +- `Keval.create { … }` → `Keval.create(KevalNumbers.Double) { … }`. +- Operator implementations: `(Double, Double) -> Double` → `(N, N) -> N`; `(Double) -> Double` → `(N) -> N`. +- Function implementations: `(DoubleArray) -> Double` → `(List) -> N`. +- `KevalBuilder.DEFAULT_RESOURCES` removed; use `KevalNumbers.Double.defaultResources()`. +- `KevalBuilder` constructor is internal; build instances through `Keval.create(number) { … }`. + +### Fixed + ## [1.2.0] ### Added From 924d81075feb0f7309f8a198b2942a9b0c1a4b87 Mon Sep 17 00:00:00 2001 From: notKamui Date: Sat, 23 May 2026 19:10:51 +0200 Subject: [PATCH 3/4] Update CHANGELOG and README for version 2.0.0 release. Introduced a generic number type API with `KevalNumber`, updated entry points, and added support for variables and non-throwing evaluation methods. Enhanced documentation to reflect changes and migration instructions from v1.x to v2.x. --- .../generic_keval_number_api_ce57ea4a.plan.md | 276 ------------------ CHANGELOG.md | 26 +- README.md | 61 +++- .../com/notkamui/keval/AbstractSyntaxTree.kt | 107 ++++--- .../kotlin/com/notkamui/keval/BooleanLogic.kt | 48 +++ .../com/notkamui/keval/CompiledExpression.kt | 29 ++ .../kotlin/com/notkamui/keval/Grammar.kt | 38 +-- .../kotlin/com/notkamui/keval/Keval.kt | 156 +++------- .../com/notkamui/keval/KevalException.kt | 47 +-- .../kotlin/com/notkamui/keval/KevalNumber.kt | 50 +++- .../com/notkamui/keval/KevalNumberDouble.kt | 209 ++++++------- .../kotlin/com/notkamui/keval/Tokenizer.kt | 37 +-- .../kotlin/com/notkamui/keval/ASTTest.kt | 2 +- .../notkamui/keval/CompiledExpressionTest.kt | 39 +++ .../com/notkamui/keval/DSLResourcesTests.kt | 33 ++- .../kotlin/com/notkamui/keval/GrammarTest.kt | 4 +- .../com/notkamui/keval/TokenizerTest.kt | 26 +- .../kotlin/com/notkamui/keval/VariableTest.kt | 80 +++++ .../notkamui/keval/KevalNumberBigDecimal.kt | 184 ++++++------ .../keval/KevalBigDecimalBuilderTest.kt | 6 +- .../keval/KevalBigDecimalErrorsTest.kt | 4 +- .../keval/KevalBigDecimalParsingTest.kt | 24 +- .../keval/KevalBigDecimalVariableTest.kt | 24 ++ 23 files changed, 736 insertions(+), 774 deletions(-) delete mode 100644 .cursor/plans/generic_keval_number_api_ce57ea4a.plan.md create mode 100644 src/commonMain/kotlin/com/notkamui/keval/BooleanLogic.kt create mode 100644 src/commonMain/kotlin/com/notkamui/keval/CompiledExpression.kt create mode 100644 src/commonTest/kotlin/com/notkamui/keval/CompiledExpressionTest.kt create mode 100644 src/commonTest/kotlin/com/notkamui/keval/VariableTest.kt create mode 100644 src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalVariableTest.kt diff --git a/.cursor/plans/generic_keval_number_api_ce57ea4a.plan.md b/.cursor/plans/generic_keval_number_api_ce57ea4a.plan.md deleted file mode 100644 index 1162b17..0000000 --- a/.cursor/plans/generic_keval_number_api_ce57ea4a.plan.md +++ /dev/null @@ -1,276 +0,0 @@ ---- -name: Generic Keval Number API -overview: Refactor Keval into a generic `Keval` pipeline driven by a `KevalNumber` typeclass, with `Double` as the common default and JVM/Android `BigDecimal` support (shared source set) with a minimal built-in set — no external dependencies. -todos: - - id: keval-number-interface - content: Add KevalNumber interface and KevalNumbers object with Double entry point - status: completed - - id: generic-pipeline - content: Parameterize AbstractSyntaxTree, Grammar, Tokenizer on N; thread KevalNumber through parsing - status: completed - - id: generic-keval-builder - content: Refactor Keval and KevalBuilder; move DEFAULT_RESOURCES to KevalNumberDouble - status: completed - - id: jvm-bigdecimal - content: Add jvmAndAndroidMain source set, android() target, and KevalNumberBigDecimal with arithmetic-only defaults - status: completed - - id: tests-and-docs - content: Update commonTest for generic Double API; add jvmTest for BigDecimal; bump to 2.0.0 and update README - status: completed -isProject: false ---- - -# Generic Number Type Support for Keval - -## Current State - -The entire pipeline is hardcoded to `Double`: - -```mermaid -flowchart LR - expr[String] --> tok[Tokenizer.isNumeric via toDoubleOrNull] - tok --> parse[Parser ValueNode via toDouble] - parse --> ast["Node.eval(): Double"] - ast --> api["Keval.eval(): Double"] -``` - -Every layer in [`src/commonMain/kotlin/com/notkamui/keval/`](src/commonMain/kotlin/com/notkamui/keval/) uses `(Double, Double) -> Double`, `DoubleArray`, and `kotlin.math` — there is no platform-specific code today. - -## Target Architecture - -Introduce a **typeclass** that owns parsing rules and default resources for a numeric type `N`, then parameterize the full pipeline on `N`. - -```mermaid -flowchart LR - kn["KevalNumber<N>"] --> tok[Tokenizer] - kn --> parse[Parser] - kn --> defaults[defaultResources] - parse --> ast["Node<N>.eval(): N"] - ast --> api["Keval<N>.eval(): N"] - defaults --> builder[KevalBuilder<N>] -``` - -### New core abstraction: `KevalNumber` - -Add [`KevalNumber.kt`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt): - -```kotlin -interface KevalNumber { - fun isValidLiteral(token: String): Boolean - fun parseLiteral(token: String): N - fun defaultResources(): Map> -} -``` - -- **Parsing** moves out of `toDoubleOrNull()` / `toDouble()` into the typeclass. -- **Default resources** move out of `KevalBuilder.DEFAULT_RESOURCES` into each implementation (Double keeps today's full set; BigDecimal gets a **minimal** arithmetic-focused subset — see below). - -Consumers can implement `KevalNumber` for any type (`Int`, a custom decimal, etc.) by supplying parsing + whatever defaults they want. - ---- - -## Breaking API Changes (v2.0.0) - -| Before (v1.x) | After (v2.x) | -|---|---| -| `class Keval` | `class Keval(private val number: KevalNumber, ...)` | -| `(Double, Double) -> Double` operators | `(N, N) -> N` | -| `(DoubleArray) -> Double` functions | `(List) -> N` | -| `KevalBuilder()` with static `DEFAULT_RESOURCES` | `KevalBuilder(number: KevalNumber)` | -| `String.keval(): Double` | kept as convenience, delegates to `KevalNumbers.Double` | -| `Keval.create { includeDefault() }` | `Keval.create(KevalNumbers.Double) { includeDefault() }` | - -### Convenience entry points (preserve ergonomics for Double) - -```kotlin -object KevalNumbers { - val Double: KevalNumber = KevalNumberDouble -} - -// unchanged call sites for the common case -fun String.keval(): Double = KevalNumbers.Double.eval(this) -fun String.keval(generator: KevalBuilder.() -> Unit): Double = - Keval.create(KevalNumbers.Double, generator).eval(this) - -// JVM + Android BigDecimal (java.math.BigDecimal on both platforms) -val KevalNumbers.BigDecimal: KevalNumber -``` - -`Keval.eval(expr)` companion shortcut stays as `@JvmStatic` Double-only sugar. - ---- - -## File-by-File Refactor - -### 1. Generic operator / AST types — [`AbstractSyntaxTree.kt`](src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt) - -Parameterize all internal types on `N`: - -- `KevalBinaryOperator(..., implementation: (N, N) -> N)` -- `KevalUnaryOperator(..., implementation: (N) -> N)` -- `KevalFunction(..., implementation: (List) -> N)` -- `KevalConstant(value: N)` -- `Node` with `fun eval(): N` -- `FunctionNode` collects `children.map { it.eval() }` as `List` (not `DoubleArray`) - -`KevalOperator` becomes `KevalOperator` sealed interface. - -### 2. Parser — [`Grammar.kt`](src/commonMain/kotlin/com/notkamui/keval/Grammar.kt) - -- `Parser` takes `KevalNumber` alongside operators map. -- Replace `String.isDouble()` / `token.toDouble()` with `number.isValidLiteral(token)` / `number.parseLiteral(token)`. -- `String.toAST(number: KevalNumber, operators: Map>): Node`. - -### 3. Tokenizer — [`Tokenizer.kt`](src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt) - -- `String.isNumeric(number: KevalNumber)` delegates to `number.isValidLiteral(this)`. -- `normalizeTokens` / `tokenize` accept `KevalNumber` (threaded from `toAST`). -- Extend `TOKENIZER_REGEX` to support scientific notation (`1.23e4`, `1e-10`) — needed for BigDecimal literals and improves Double parsing. Keep backward-compatible plain integer/decimal forms. - -### 4. Builder — [`KevalBuilder.kt`](src/commonMain/kotlin/com/notkamui/keval/KevalBuilder.kt) - -- `class KevalBuilder(private val number: KevalNumber, baseResources: ... = emptyMap())` -- `includeDefault()` → `resources += number.defaultResources()` -- All builder `implementation` fields become `(N, N) -> N`, `(N) -> N`, `(List) -> N`. -- `build(): Keval` -- **Delete** the 100+ line `DEFAULT_RESOURCES` companion — it moves to `KevalNumberDouble`. - -### 5. Public API — [`Keval.kt`](src/commonMain/kotlin/com/notkamui/keval/Keval.kt) - -- `class Keval internal constructor(private val number: KevalNumber, private val resources: ...)` -- All `with*` methods take generic lambdas; return `Keval`. -- `eval(mathExpression: String): N` calls `mathExpression.toAST(number, resourcesView()).eval()`. -- `companion object.create(number: KevalNumber, generator: KevalBuilder.() -> Unit): Keval` - -### 6. Double implementation — new [`KevalNumberDouble.kt`](src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt) - -Move today's [`KevalBuilder.DEFAULT_RESOURCES`](src/commonMain/kotlin/com/notkamui/keval/KevalBuilder.kt) verbatim into `KevalNumberDouble.defaultResources()`, adapting signatures to `(N, N) -> N` / `List`. - -Parsing: `isValidLiteral` → `toDoubleOrNull() != null`; `parseLiteral` → `toDouble()`. - -Extract shared boolean helpers (`doubleToBoolean`, `reduceBoolean`, etc.) as private functions in this file. - ---- - -## JVM / Android BigDecimal Support - -No external dependencies — only `java.math.BigDecimal` and `MathContext`, available on both JVM and Android. - -### Platform availability - -| Target | BigDecimal API | Notes | -|---|---|---| -| JVM | Yes | via `jvmAndAndroidMain` | -| Android | Yes | via `jvmAndAndroidMain` (same implementation) | -| JS / Native (iOS, etc.) | No | `KevalNumbers.Double` only; types not compiled in | - -**Why not `jvmMain` alone?** In KMP, `jvmMain` is compiled only into the JVM artifact. Android uses a separate `androidMain` source set — code in `jvmMain` is invisible to Android consumers even though `java.math.BigDecimal` exists on Android. - -### New targets and source sets in [`build.gradle.kts`](build.gradle.kts) - -```kotlin -kotlin { - jvm() - androidTarget() // new - - // ... existing js/native targets ... - - sourceSets { - val jvmAndAndroidMain by creating { - dependsOn(commonMain.get()) - } - jvmMain.get().dependsOn(jvmAndAndroidMain) - androidMain.get().dependsOn(jvmAndAndroidMain) - - val jvmAndAndroidTest by creating { - dependsOn(commonTest.get()) - } - jvmTest.get().dependsOn(jvmAndAndroidTest) - androidUnitTest.get().dependsOn(jvmAndAndroidTest) - } -} -``` - -BigDecimal implementation lives in `jvmAndAndroidMain` — compiled into both JVM and Android publications. No extra dependencies. - -### New file — [`src/jvmAndAndroidMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt`](src/jvmAndAndroidMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) - -```kotlin -object KevalNumberBigDecimal : KevalNumber { - val mathContext: MathContext = MathContext.DECIMAL128 - - override fun isValidLiteral(token: String): Boolean = ... - override fun parseLiteral(token: String): BigDecimal = ... - override fun defaultResources(): Map> = ... -} -``` - -**Included in BigDecimal defaults** (native `BigDecimal` / `compareTo` / `RoundingMode` only): - -| Category | Operators / functions | -|---|---| -| Binary | `+`, `-`, `*`, `/`, `%`, `^` (integer exponent via `pow(int)` only) | -| Unary | `-` (negate), `+` (identity) | -| Functions | `neg`, `abs`, `sign`, `min`, `max`, `sum`, `avg`, `ceil`, `floor`, `round`, `trunc` | -| Comparison / logical | `bool`, `not`, `and`, `nand`, `or`, `nor`, `xor`, `xnor`, `imply`, `nimply`, `eq`, `ne`, `gt`, `lt`, `ge`, `le` | -| Constants | none (consumers can add `PI` / `e` via `withConstant` or a custom `KevalNumber` wrapper) | - -Implementation notes: -- Div-by-zero: `compareTo(ZERO) == 0` → `KevalZeroDivisionException` -- Truthiness: `compareTo(ZERO) != 0` -- `^` with non-integer exponent: throw `KevalInvalidArgumentException` (no approximation library) -- `avg`: `sum / size` using configured `MathContext` -- Rounding ops: `setScale` / `RoundingMode` - -**Explicitly excluded** (require transcendental math or heavy custom code — users add via `KevalBuilder` if needed): - -- Trig: `sin`, `cos`, `tan`, `asin`, `acos`, `atan` -- Roots / powers: `sqrt`, `cbrt`, `nthrt`, fractional `^` -- Logs / exp: `exp`, `ln`, `log10`, `log2` -- Random: `rand`, `randRange` -- Other: `!` (factorial), `median`, `percentile`, constants `PI` / `e` - -Expose via `KevalNumbers.BigDecimal` in the same `jvmAndAndroidMain` file (or a small `KevalNumbers.jvmAndAndroid.kt`). - -### JVM / Android convenience extensions - -```kotlin -fun String.kevalBigDecimal( - generator: KevalBuilder.() -> Unit = { includeDefault() } -): BigDecimal = Keval.create(KevalNumberBigDecimal, generator).eval(this) -``` - ---- - -## Tests - -Update all tests in [`src/commonTest/`](src/commonTest/kotlin/com/notkamui/keval/) to use `KevalNumbers.Double` / `Keval.create(KevalNumbers.Double)`. - -Add [`src/jvmAndAndroidTest/kotlin/com/notkamui/keval/KevalBigDecimalTest.kt`](src/jvmAndAndroidTest/kotlin/com/notkamui/keval/KevalBigDecimalTest.kt) (runs on both JVM and Android unit tests): - -- Precision cases that fail with Double (e.g. `0.1 + 0.2`). -- Smoke test each included default category (arithmetic, comparison, logical, aggregates, rounding). -- Div-by-zero, non-integer `^`, and invalid-argument paths. -- Confirm excluded functions (e.g. `sin`) are **not** in defaults but can be added via builder. - -Existing Double tests should remain green with minimal assertion changes (same expected values). - ---- - -## Versioning and Docs - -- Bump version to **2.0.0** in [`build.gradle.kts`](build.gradle.kts). -- Update [`README.md`](README.md) with: - - Generic API section (`KevalNumber`, `Keval.create(number) { ... }`). - - JVM/Android BigDecimal section with `KevalNumbers.BigDecimal` / `kevalBigDecimal()`, documenting the **reduced** default set vs Double and which platforms support it. - - Migration guide from v1.x (type signature changes, `KevalBuilder` now requires a number context). - ---- - -## Design Notes / Non-Goals - -- **JS / Native targets** remain Double-only; BigDecimal types are not compiled into those artifacts. -- **Android**: added as a new publish target; shares BigDecimal code with JVM via `jvmAndAndroidMain`. -- **`MathContext` configurability**: ship with `DECIMAL128` default; a follow-up could add `KevalNumberBigDecimal.withContext(MathContext)` if consumers need per-evaluation precision control. -- **BigDecimal transcendental functions**: intentionally out of scope for built-in defaults; consumers can register custom functions via `KevalBuilder` (possibly wrapping a third-party decimal math lib themselves). -- **Custom numeric types**: fully supported — implement `KevalNumber` and pass it to `Keval.create`; only parsing + defaults need type-specific logic; tokenizer/parser/AST are generic. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b84720..5ddab40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,19 @@ ### Added - Generic numeric type support via [`KevalNumber`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt): parsing rules and default resources are defined per number type. -- [`KevalNumbers.Double`](src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt) — full default operator/function/constant set (same behaviour as v1.x). -- [`KevalNumbers.BigDecimal`](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) — JVM-only built-in for `java.math.BigDecimal` with arithmetic, comparison, aggregates, and rounding defaults (no trig/log/random). -- [`String.kevalBigDecimal()`](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) and `KevalNumber.eval(String)` convenience entry points. +- Split typeclass interfaces: [`KevalLiteralParser`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt), [`KevalDefaults`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt), and [`KevalNumber`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt) (includes non-overridable implicit multiplication via `multiply()`). +- [`KevalNumbers.real`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt) — primary name for the built-in `Double` implementation (full default operator/function/constant set, same behaviour as v1.x). +- [`KevalNumbers.BigDecimal`](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) — JVM-only built-in for `java.math.BigDecimal` with arithmetic, comparison, aggregates, and rounding defaults (no trig/log/random). Configurable precision via [`KevalNumberBigDecimal.withContext(MathContext)`](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt). +- [`CompiledExpression`](src/commonMain/kotlin/com/notkamui/keval/CompiledExpression.kt) and [`Keval.compile()`](src/commonMain/kotlin/com/notkamui/keval/Keval.kt) — parse once, evaluate many times. +- **Variables**: identifiers that are not operators, functions, or constants; [`eval(expression, bindings)`](src/commonMain/kotlin/com/notkamui/keval/Keval.kt), [`KevalUnresolvedVariableException`](src/commonMain/kotlin/com/notkamui/keval/KevalException.kt), implicit multiplication with variables (`x(y+1)`, `2 x`). +- Unified entry points: [`String.evalWith()`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt), [`String.compileWith()`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt), [`KevalNumber.eval()`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt). +- Non-throwing API: [`evalOrNull`](src/commonMain/kotlin/com/notkamui/keval/Keval.kt) / [`evalResult`](src/commonMain/kotlin/com/notkamui/keval/Keval.kt) on `Keval` and `CompiledExpression`; `String.kevalOrNull()` / `String.kevalResult()` for `Double`. +- [`String.kevalBigDecimal()`](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) JVM convenience entry point. - Scientific notation in numeric literals (e.g. `1e10`, `1.5e-3`). - Negative integer exponents for BigDecimal `^` (e.g. `2 ^ -2` → `0.25`). -- Extensive JVM test suite for BigDecimal evaluation, parsing, builder API, and error cases. +- Shared [`BooleanLogic`](src/commonMain/kotlin/com/notkamui/keval/BooleanLogic.kt) defaults for Double and BigDecimal. +- Fixed-arity function AST nodes (`Function1Node`–`Function4Node`) to reduce allocations during parsing. +- Extensive test suite for variables, compiled expressions, BigDecimal evaluation, parsing, builder API, and error cases. ### Changed @@ -16,18 +23,25 @@ - `String.keval()` and `Keval.eval(String)` remain `Double`-only shortcuts with the same ergonomics as v1.x. - Android, JS, and Native targets continue to use the `Double` API from `commonMain` without an explicit Android publication target. +- Implicit multiplication behaviour is unchanged: `(2+3)(4+6)`, `3(2+2)`, `1 2` still work; `*` cannot be overridden by consumers. #### Breaking - `Keval` is now `Keval` and requires a [`KevalNumber`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt) context. -- `Keval.create { … }` → `Keval.create(KevalNumbers.Double) { … }`. +- `Keval.create { … }` → `Keval.create(KevalNumbers.real) { … }`. +- `KevalNumbers.Double` removed; use `KevalNumbers.real`. - Operator implementations: `(Double, Double) -> Double` → `(N, N) -> N`; `(Double) -> Double` → `(N) -> N`. - Function implementations: `(DoubleArray) -> Double` → `(List) -> N`. -- `KevalBuilder.DEFAULT_RESOURCES` removed; use `KevalNumbers.Double.defaultResources()`. +- `KevalBuilder.DEFAULT_RESOURCES` removed; use `KevalNumbers.real.defaultResources()`. - `KevalBuilder` constructor is internal; build instances through `Keval.create(number) { … }`. +- `KevalNumberBigDecimal` is now a class (use `KevalNumberBigDecimal.Default` or `KevalNumbers.BigDecimal`) instead of an `object`. +- `KevalInvalidExpressionException` is sealed; direct instantiation replaced by subtypes such as `KevalInvalidSymbolException`. ### Fixed +- `randRange` now correctly rejects non-positive step values. +- BigDecimal `ne` aligned with `eq`: uses `compareTo` for scale-independent numeric equality. + ## [1.2.0] ### Added diff --git a/README.md b/README.md index a3ac15e..fe03fdd 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Keval.eval("(3+4)(2/8 * 5) % PI") // uses default resources "(3+4)(2/8 * 5) % PI".keval() // extension ; uses default resources -Keval.create(KevalNumbers.Double) { // builder instance +Keval.create(KevalNumbers.real) { // builder instance includeDefault() // this function includes the built-in resources binaryOperator { // this function adds a binary operator ; you can call it several times @@ -220,7 +220,7 @@ many `eval` as you need. In concordance with creating a Keval instance, you can also add resources like this: ```Kotlin -val kvl = Keval.create(KevalNumbers.Double) { includeDefault() } +val kvl = Keval.create(KevalNumbers.real) { includeDefault() } .withBinaryOperator( // includes a new binary operator ';', // symbol 3, // precedence @@ -262,7 +262,7 @@ In addition, the symbols `(`,`)`,`,` are reserved and trying to create operator ## Generic number types -Keval is generic over the numeric result type via [KevalNumber](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt). The default is [Double](src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt) on all platforms (JVM, JS, Native, Android via `commonMain`). +Keval is generic over the numeric result type via [KevalNumber](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt). The default is [Double](src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt) on all platforms (JVM, JS, Native, Android via `commonMain`). Use [KevalNumbers.real](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt) as the primary name for the built-in `Double` implementation. ```Kotlin // Custom numeric type: implement KevalNumber and pass it to Keval.create @@ -274,13 +274,44 @@ Keval.create(myNumber) { implementation = { args -> args[0] + args[0] } } }.eval("twice(21)") + +// Unified entry points for any number type +"1 + 2".evalWith(KevalNumbers.real) +val compiled = "x * 2".compileWith(KevalNumbers.real) ``` Function implementations take `List` instead of `DoubleArray`. The `String.keval()` extension and `Keval.eval(String)` companion remain `Double`-only shortcuts. +### Compile once, evaluate many times + +Parsing is the expensive part. Use [`compile()`](src/commonMain/kotlin/com/notkamui/keval/Keval.kt) or [`String.compileWith()`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt) to produce a [`CompiledExpression`](src/commonMain/kotlin/com/notkamui/keval/CompiledExpression.kt) that can be evaluated repeatedly: + +```Kotlin +val keval = Keval.create(KevalNumbers.real) { includeDefault() } +val expr = keval.compile("2 + rate * hours") +expr.eval(mapOf("rate" to 25.0, "hours" to 8.0)) // 202.0 +expr.variables // setOf("rate", "hours") +``` + +### Variables + +Identifiers that are not operators, functions, or constants are treated as variables. Constants and functions take precedence over variable names with the same spelling. + +```Kotlin +val keval = Keval.create(KevalNumbers.real) { includeDefault() } +keval.eval("x + y", mapOf("x" to 3.0, "y" to 7.0)) // 10.0 +keval.compile("x(y + 1)").eval(mapOf("x" to 2.0, "y" to 2.0)) // implicit mul: 6.0 +``` + +Unresolved variables throw [`KevalUnresolvedVariableException`](src/commonMain/kotlin/com/notkamui/keval/KevalException.kt). + +### Non-throwing evaluation + +[`evalOrNull`](src/commonMain/kotlin/com/notkamui/keval/Keval.kt) and [`evalResult`](src/commonMain/kotlin/com/notkamui/keval/Keval.kt) catch [`KevalException`](src/commonMain/kotlin/com/notkamui/keval/KevalException.kt) only (not arbitrary throwables). The same variants exist on [`CompiledExpression`](src/commonMain/kotlin/com/notkamui/keval/CompiledExpression.kt) and as `String.kevalOrNull()` / `String.kevalResult()` for `Double`. + ### BigDecimal (JVM only) -On the JVM artifact, [KevalNumberBigDecimal](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) provides a reduced default set (arithmetic, comparison, aggregates, rounding — no trig/log/random). Other targets continue to use `Double` through `commonMain`. +On the JVM artifact, [KevalNumberBigDecimal](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) provides a reduced default set (arithmetic, comparison, aggregates, rounding — no trig/log/random). Decimal comparison operators (`eq`, `ne`, `gt`, …) use numeric equality via `compareTo`, not scale equality. Other targets continue to use `Double` through `commonMain`. ```Kotlin "0.1 + 0.2".kevalBigDecimal() // BigDecimal("0.3") @@ -288,16 +319,24 @@ On the JVM artifact, [KevalNumberBigDecimal](src/jvmMain/kotlin/com/notkamui/kev Keval.create(KevalNumbers.BigDecimal) { includeDefault() }.eval("sum(1, 2, 3)") + +// Configurable precision +val lowPrecision = KevalNumberBigDecimal.withContext(MathContext(4)) +Keval.create(lowPrecision) { includeDefault() }.eval("1 / 3") // 0.3333 ``` +#### BigDecimal on Android + +The JVM `jvmMain` artifact (including `KevalNumberBigDecimal`) is not on the Android classpath. Android apps can still use `Double` via `KevalNumbers.real` from `commonMain`. For `BigDecimal` on Android, implement [`KevalNumber`](src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt) locally — copy or adapt the defaults from [KevalNumberBigDecimal](src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt) using `java.math.BigDecimal`. + ### Migrating from v1.x | v1.x | v2.x | |---|---| -| `Keval.create { includeDefault() }` | `Keval.create(KevalNumbers.Double) { includeDefault() }` | +| `Keval.create { includeDefault() }` | `Keval.create(KevalNumbers.real) { includeDefault() }` | | `(Double, Double) -> Double` operators | `(N, N) -> N` | | `(DoubleArray) -> Double` functions | `(List) -> N` | -| `KevalBuilder.DEFAULT_RESOURCES` | `KevalNumbers.Double.defaultResources()` | +| `KevalBuilder.DEFAULT_RESOURCES` | `KevalNumbers.real.defaultResources()` | `String.keval()` and `Keval.eval(expr)` are unchanged for `Double`. @@ -308,19 +347,17 @@ In case of an error, Keval will throw one of several `KevalException`s: - `KevalZeroDivisionException` in the case a zero division occurs - `KevalInvalidArgumentException` in the case a operator or function is called with an invalid argument (i.e. a negative number for a factorial) -- `KevalInvalidExpressionException` if the expression is invalid, with the following properties: +- `KevalInvalidExpressionException` if the expression is invalid (sealed; includes malformed syntax), with the following properties: - `expression` contains the fully sanitized expression - `position` is an estimate of the position of the error - `KevalInvalidSymbolException` if the expression contains an invalid operator, with the following properties: - `invalidSymbol` contains the actual invalid operator - `expression` contains the fully sanitized expression - `position` is an estimate of the position of the error +- `KevalUnresolvedVariableException` if a variable is used without a binding - `KevalDSLException` if, in the DSL, one of the field is either not set, or doesn't follow its restrictions (defined above) -`KevalZeroDivisionException` and `KevalInvalidArgumentException` are instantiable so that you can throw them when -implementing a custom operator/function. - -## Future Plans +`KevalZeroDivisionException`, `KevalInvalidArgumentException`, and `KevalUnresolvedVariableException` are instantiable so that you can throw them when implementing a custom operator/function. -- Support for variables (will produce a `DoubleArray` instead of a single `Double`) +Use `evalOrNull` / `evalResult` (or `String.kevalOrNull()` / `String.kevalResult()` for `Double`) when you prefer not to catch exceptions manually. diff --git a/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt b/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt index d8e9a29..7662a7c 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt @@ -7,10 +7,6 @@ sealed interface KevalOperator /** * Represents a binary operator - * - * @property precedence is the precedence of the operator - * @property isLeftAssociative is true if the operator is left associative, false otherwise - * @property implementation is the actual implementation of the operator */ internal data class KevalBinaryOperator( val precedence: Int, @@ -30,9 +26,6 @@ internal data class KevalBothOperator( /** * Represents a function - * - * @property arity is the arity of the function (how many arguments it takes). If null, the function is variadic - * @property implementation is the actual implementation of the function */ internal data class KevalFunction( val arity: Int?, @@ -41,8 +34,6 @@ internal data class KevalFunction( /** * Represents a constant - * - * @property value is the value of the constant */ internal data class KevalConstant( val value: N @@ -50,57 +41,101 @@ internal data class KevalConstant( /** * Represents a node in an AST and can evaluate its value - * - * Can either be an operator, or a leaf (a value) */ internal interface Node { - /** - * Evaluates the value of this node - * - * @return the value of the node - * @throws KevalZeroDivisionException in case of a zero division - */ - fun eval(): N + fun eval(bindings: Map = emptyMap()): N + fun collectVariables(): Set } -/** - * An binary operator node - * - * @property left is its left child - * @property op is the actual operator - * @property right is its right child - * @constructor Creates an operator node - */ internal data class BinaryOperatorNode( private val left: Node, private val op: (N, N) -> N, private val right: Node ) : Node { - override fun eval(): N = op(left.eval(), right.eval()) + override fun eval(bindings: Map): N = op(left.eval(bindings), right.eval(bindings)) + override fun collectVariables(): Set = left.collectVariables() + right.collectVariables() } internal data class UnaryOperatorNode( private val op: (N) -> N, private val child: Node ) : Node { - override fun eval(): N = op(child.eval()) + override fun eval(bindings: Map): N = op(child.eval(bindings)) + override fun collectVariables(): Set = child.collectVariables() } internal data class FunctionNode( private val func: (List) -> N, private val children: List> ) : Node { - override fun eval(): N = func(children.map { it.eval() }) + override fun eval(bindings: Map): N = func(children.map { it.eval(bindings) }) + override fun collectVariables(): Set = children.flatMap { it.collectVariables() }.toSet() +} + +internal data class Function1Node( + private val func: (List) -> N, + private val arg: Node, +) : Node { + override fun eval(bindings: Map): N = func(listOf(arg.eval(bindings))) + override fun collectVariables(): Set = arg.collectVariables() +} + +internal data class Function2Node( + private val func: (List) -> N, + private val arg1: Node, + private val arg2: Node, +) : Node { + override fun eval(bindings: Map): N = func(listOf(arg1.eval(bindings), arg2.eval(bindings))) + override fun collectVariables(): Set = arg1.collectVariables() + arg2.collectVariables() +} + +internal data class Function3Node( + private val func: (List) -> N, + private val arg1: Node, + private val arg2: Node, + private val arg3: Node, +) : Node { + override fun eval(bindings: Map): N = + func(listOf(arg1.eval(bindings), arg2.eval(bindings), arg3.eval(bindings))) + override fun collectVariables(): Set = + arg1.collectVariables() + arg2.collectVariables() + arg3.collectVariables() +} + +internal data class Function4Node( + private val func: (List) -> N, + private val arg1: Node, + private val arg2: Node, + private val arg3: Node, + private val arg4: Node, +) : Node { + override fun eval(bindings: Map): N = + func(listOf(arg1.eval(bindings), arg2.eval(bindings), arg3.eval(bindings), arg4.eval(bindings))) + override fun collectVariables(): Set = + arg1.collectVariables() + arg2.collectVariables() + arg3.collectVariables() + arg4.collectVariables() } -/** - * A value node (leaf) - * - * @property value is its value - * @constructor Creates a value node - */ internal data class ValueNode( private val value: N ) : Node { - override fun eval(): N = value + override fun eval(bindings: Map): N = value + override fun collectVariables(): Set = emptySet() +} + +internal data class VariableNode( + val name: String, +) : Node { + override fun eval(bindings: Map): N = + bindings[name] ?: throw KevalUnresolvedVariableException(name) + override fun collectVariables(): Set = setOf(name) +} + +internal fun createFunctionNode( + func: (List) -> N, + args: List>, +): Node = when (args.size) { + 1 -> Function1Node(func, args[0]) + 2 -> Function2Node(func, args[0], args[1]) + 3 -> Function3Node(func, args[0], args[1], args[2]) + 4 -> Function4Node(func, args[0], args[1], args[2], args[3]) + else -> FunctionNode(func, args) } diff --git a/src/commonMain/kotlin/com/notkamui/keval/BooleanLogic.kt b/src/commonMain/kotlin/com/notkamui/keval/BooleanLogic.kt new file mode 100644 index 0000000..7b0809c --- /dev/null +++ b/src/commonMain/kotlin/com/notkamui/keval/BooleanLogic.kt @@ -0,0 +1,48 @@ +package com.notkamui.keval + +internal object BooleanLogic { + + fun operators( + isTruthy: (N) -> Boolean, + trueValue: N, + falseValue: N, + compare: (N, N) -> Int, + ): Map> { + fun toBool(value: Boolean) = if (value) trueValue else falseValue + + fun List.reduceBoolean(invert: Boolean = false, operation: (Boolean, Boolean) -> Boolean): N = + toBool( + map(isTruthy).reduce(operation).let { if (invert) !it else it } + ) + + fun booleanOperation(array: List, operation: (Boolean, Boolean) -> Boolean): N = + toBool(operation(isTruthy(array[0]), isTruthy(array[1]))) + + fun allEqual(args: List): Boolean = + args.all { compare(it, args[0]) == 0 } + + fun allDistinct(args: List): Boolean = + args.indices.all { i -> + args.indices.all { j -> i == j || compare(args[i], args[j]) != 0 } + } + + return mapOf( + "bool" to KevalFunction(1) { toBool(isTruthy(it[0])) }, + "not" to KevalFunction(1) { toBool(!isTruthy(it[0])) }, + "and" to KevalFunction(null) { it.reduceBoolean { a, b -> a && b } }, + "nand" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a && b } }, + "or" to KevalFunction(null) { it.reduceBoolean { a, b -> a || b } }, + "nor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a || b } }, + "xor" to KevalFunction(null) { it.reduceBoolean { a, b -> a xor b } }, + "xnor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a xor b } }, + "imply" to KevalFunction(2) { booleanOperation(it) { a, b -> !a || b } }, + "nimply" to KevalFunction(2) { booleanOperation(it) { a, b -> a && !b } }, + "eq" to KevalFunction(null) { toBool(allEqual(it)) }, + "ne" to KevalFunction(null) { toBool(allDistinct(it)) }, + "gt" to KevalFunction(2) { toBool(compare(it[0], it[1]) > 0) }, + "lt" to KevalFunction(2) { toBool(compare(it[0], it[1]) < 0) }, + "ge" to KevalFunction(2) { toBool(compare(it[0], it[1]) >= 0) }, + "le" to KevalFunction(2) { toBool(compare(it[0], it[1]) <= 0) }, + ) + } +} diff --git a/src/commonMain/kotlin/com/notkamui/keval/CompiledExpression.kt b/src/commonMain/kotlin/com/notkamui/keval/CompiledExpression.kt new file mode 100644 index 0000000..e5f466e --- /dev/null +++ b/src/commonMain/kotlin/com/notkamui/keval/CompiledExpression.kt @@ -0,0 +1,29 @@ +package com.notkamui.keval + +/** + * A compiled mathematical expression that can be evaluated repeatedly without re-parsing. + */ +class CompiledExpression internal constructor( + private val root: Node, + val variables: Set, +) { + fun eval(): N = eval(emptyMap()) + + fun eval(bindings: Map): N = root.eval(bindings) + + fun evalOrNull(): N? = evalOrNull(emptyMap()) + + fun evalOrNull(bindings: Map): N? = try { + eval(bindings) + } catch (_: KevalException) { + null + } + + fun evalResult(): Result = evalResult(emptyMap()) + + fun evalResult(bindings: Map): Result = try { + Result.success(eval(bindings)) + } catch (e: KevalException) { + Result.failure(e) + } +} diff --git a/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt b/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt index 6ab0632..bb5f2b1 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt @@ -10,14 +10,14 @@ internal class Parser( ) { private var currentTokenOrNull: String? = tokens.next() private val currentToken: String - get() = currentTokenOrNull ?: throw KevalInvalidExpressionException(tokensToString, -1) + get() = currentTokenOrNull ?: throw KevalMalformedExpressionException(tokensToString, -1) private var currentPos = 0 private var openParenthesesCount = 0 private fun consume(expected: String) { if (currentTokenOrNull != expected) { - throw KevalInvalidExpressionException( + throw KevalMalformedExpressionException( tokensToString, currentPos, "expected $expected but found ${currentTokenOrNull ?: "end of expression"}", @@ -28,7 +28,7 @@ internal class Parser( openParenthesesCount++ } else if (currentToken == ")") { if (openParenthesesCount == 0) { - throw KevalInvalidExpressionException( + throw KevalMalformedExpressionException( tokensToString, currentPos, "unexpected closing parenthesis" @@ -95,7 +95,7 @@ internal class Parser( while (currentTokenOrNull != ")") { args.add(expression()) if (op.arity != null && args.size > op.arity) { - throw KevalInvalidExpressionException( + throw KevalMalformedExpressionException( tokensToString, currentPos, "expected ${op.arity} ${"argument".pluralize(op.arity)} but found ${args.size}", @@ -107,13 +107,13 @@ internal class Parser( } consume(")") if (op.arity != null && args.size < op.arity) { - throw KevalInvalidExpressionException( + throw KevalMalformedExpressionException( tokensToString, currentPos, "expected ${op.arity} ${"argument".pluralize(op.arity)} but found ${args.size}", ) } - return FunctionNode(op.implementation, args) + return createFunctionNode(op.implementation, args) } private fun handleConstant(): Node { @@ -148,21 +148,25 @@ internal class Parser( } } val token = currentToken - if (!number.isValidLiteral(token)) { - throw KevalInvalidExpressionException( - tokensToString, - currentPos, - "expected number or symbol but found $token", - ) + if (number.isValidLiteral(token)) { + consume(currentToken) + return ValueNode(number.parseLiteral(token)) } - consume(currentToken) - return ValueNode(number.parseLiteral(token)) + if (token.isIdentifierName()) { + consume(currentToken) + return VariableNode(token) + } + throw KevalMalformedExpressionException( + tokensToString, + currentPos, + "expected number or symbol but found $token", + ) } fun parse(): Node { val node = expression() if (currentTokenOrNull != null) { - throw KevalInvalidExpressionException( + throw KevalMalformedExpressionException( tokensToString, currentPos, "unexpected token $currentTokenOrNull" @@ -179,14 +183,14 @@ internal class Parser( * @receiver the string to convert * @return the abstract syntax tree * @throws KevalInvalidSymbolException if the expression contains an invalid symbol - * @throws KevalInvalidExpressionException if the expression is invalid (i.e. mismatched parenthesis, missing operand, or empty expression) + * @throws KevalMalformedExpressionException if the expression is invalid (i.e. mismatched parenthesis, missing operand, or empty expression) */ internal fun String.toAST( number: KevalNumber, operators: Map>, ): Node { if (this.replace("""[()]""".toRegex(), "").isBlank()) - throw KevalInvalidExpressionException("", -1) + throw KevalMalformedExpressionException("", -1) val tokens = this.tokenize(number, operators) val tokensToString = tokens.joinToString("") diff --git a/src/commonMain/kotlin/com/notkamui/keval/Keval.kt b/src/commonMain/kotlin/com/notkamui/keval/Keval.kt index 8dadc54..087d257 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/Keval.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/Keval.kt @@ -9,19 +9,21 @@ import kotlin.jvm.JvmStatic */ class Keval internal constructor( private val number: KevalNumber, - private val resources: Map> + private val resources: Map>, + private val operators: Map>, ) { - /** - * Creates a new instance which contains a binary operator. - * - * @param symbol The symbol representing the operator. - * @param precedence The precedence of the operator. - * @param isLeftAssociative Whether the operator is left associative. - * @param implementation The implementation of the operator. - * @return This Keval instance. - * @throws KevalDSLException If one of the fields isn't set properly. - */ + internal constructor( + number: KevalNumber, + resources: Map>, + ) : this( + number = number, + resources = resources, + operators = resources + ( + "*" to KevalBinaryOperator(3, true) { a, b -> number.multiply(a, b) } + ), + ) + fun withBinaryOperator( symbol: Char, precedence: Int, @@ -36,16 +38,6 @@ class Keval internal constructor( } .build() - - /** - * Adds a unary operator to this Keval instance. - * - * @param symbol The symbol representing the operator. - * @param isPrefix Whether the operator is prefix. - * @param implementation The implementation of the operator. - * @return This Keval instance. - * @throws KevalDSLException If one of the fields isn't set properly. - */ fun withUnaryOperator( symbol: Char, isPrefix: Boolean, @@ -58,15 +50,6 @@ class Keval internal constructor( } .build() - /** - * Adds a function to this Keval instance. - * - * @param name The name of the function. - * @param arity The number of arguments the function takes. `null` if the function should be variadic. - * @param implementation The implementation of the function. - * @return This Keval instance. - * @throws KevalDSLException If one of the fields isn't set properly. - */ fun withFunction( name: String, arity: Int? = null, @@ -79,14 +62,6 @@ class Keval internal constructor( } .build() - /** - * Adds a constant to this Keval instance. - * - * @param name The name of the constant. - * @param value The value of the constant. - * @return This Keval instance. - * @throws KevalDSLException If one of the fields isn't set properly. - */ fun withConstant( name: String, value: N @@ -97,48 +72,40 @@ class Keval internal constructor( } .build() - /** - * Adds the default resources to this Keval instance. - * - * @return This Keval instance. - */ fun withDefault(): Keval = KevalBuilder(number, resources).includeDefault().build() - /** - * Evaluates a mathematical expression. - * - * @param mathExpression The mathematical expression to evaluate. - * @return The result of the evaluation. - * @throws KevalInvalidSymbolException If there's an invalid operator in the expression. - * @throws KevalInvalidExpressionException If the expression is invalid (i.e., mismatched parentheses). - * @throws KevalZeroDivisionException If a division by zero occurs. - */ - fun eval( - mathExpression: String, - ): N { - val operators = resourcesView() - return mathExpression.toAST(number, operators).eval() + fun compile(mathExpression: String): CompiledExpression { + val root = mathExpression.toAST(number, operators) + return CompiledExpression(root, root.collectVariables()) + } + + fun eval(mathExpression: String): N = eval(mathExpression, emptyMap()) + + fun eval(mathExpression: String, bindings: Map): N = + compile(mathExpression).eval(bindings) + + fun evalOrNull(mathExpression: String): N? = evalOrNull(mathExpression, emptyMap()) + + fun evalOrNull(mathExpression: String, bindings: Map): N? = try { + eval(mathExpression, bindings) + } catch (_: KevalException) { + null + } + + fun evalResult(mathExpression: String): Result = evalResult(mathExpression, emptyMap()) + + fun evalResult(mathExpression: String, bindings: Map): Result = try { + Result.success(eval(mathExpression, bindings)) + } catch (e: KevalException) { + Result.failure(e) } /** - * Returns the resources of this [Keval] instance. - * The tokenizer assumes multiplication, hence disallowing overriding `*` operator + * Returns the operator resources of this [Keval] instance, including the non-overridable `*` operator. */ - fun resourcesView(): Map> = - resources + ("*" to requireNotNull(number.defaultResources()["*"]) { - "Number type must define a default * operator" - }) + fun resourcesView(): Map> = operators companion object { - - /** - * Creates a new instance of [Keval] with the provided resources. - * - * @param number The numeric type context for parsing and default resources. - * @param generator A lambda function that configures a KevalBuilder instance. - * @return The new instance of Keval. - * @throws KevalDSLException If one of the fields isn't set properly. - */ @JvmStatic fun create( number: KevalNumber, @@ -146,45 +113,18 @@ class Keval internal constructor( ): Keval = KevalBuilder(number).apply(generator).build() - /** - * Evaluates a mathematical expression using the default Double resources. - * - * @param mathExpression The mathematical expression to evaluate. - * @return The result of the evaluation. - * @throws KevalInvalidSymbolException If there's an invalid operator in the expression. - * @throws KevalInvalidExpressionException If the expression is invalid (i.e., mismatched parentheses). - * @throws KevalZeroDivisionException If a division by zero occurs. - */ @JvmName("evaluate") @JvmStatic - fun eval( - mathExpression: String, - ): Double = create(KevalNumbers.Double) { includeDefault() }.eval(mathExpression) + fun eval(mathExpression: String): Double = + KevalNumbers.defaultRealKeval.eval(mathExpression) } } -/** - * Evaluates a mathematical expression using the provided resources. - * - * @receiver The mathematical expression to evaluate. - * @param generator A lambda function that configures a KevalBuilder instance. - * @return The result of the evaluation. - * @throws KevalInvalidSymbolException If there's an invalid operator in the expression. - * @throws KevalInvalidExpressionException If the expression is invalid (i.e., mismatched parentheses). - * @throws KevalZeroDivisionException If a division by zero occurs. - * @throws KevalDSLException If one of the fields isn't set properly. - */ -fun String.keval( - generator: KevalBuilder.() -> Unit -): Double = Keval.create(KevalNumbers.Double, generator).eval(this) +fun String.keval(generator: KevalBuilder.() -> Unit): Double = + Keval.create(KevalNumbers.real, generator).eval(this) -/** - * Evaluates a mathematical expression using the default resources. - * - * @receiver The mathematical expression to evaluate. - * @return The result of the evaluation. - * @throws KevalInvalidSymbolException If there's an invalid operator in the expression. - * @throws KevalInvalidExpressionException If the expression is invalid (i.e., mismatched parentheses). - * @throws KevalZeroDivisionException If a division by zero occurs. - */ -fun String.keval(): Double = KevalNumbers.Double.eval(this) +fun String.keval(): Double = KevalNumbers.real.eval(this) + +fun String.kevalOrNull(): Double? = KevalNumbers.defaultRealKeval.evalOrNull(this) + +fun String.kevalResult(): Result = KevalNumbers.defaultRealKeval.evalResult(this) diff --git a/src/commonMain/kotlin/com/notkamui/keval/KevalException.kt b/src/commonMain/kotlin/com/notkamui/keval/KevalException.kt index 4ec7708..a62ea41 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/KevalException.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/KevalException.kt @@ -1,59 +1,36 @@ package com.notkamui.keval -/** - * Generic Keval Exception - * - * @param message is the message to display in the stacktrace - */ sealed class KevalException(message: String) : RuntimeException(message) -/** - * Invalid Expression Exception, is thrown when the expression is considered invalid (i.e. Mismatched parenthesis or missing operands) - * - * @property expression is the invalid expression - * @property position is the estimated position of the error - */ -open class KevalInvalidExpressionException internal constructor( +sealed class KevalInvalidExpressionException protected constructor( val expression: String, val position: Int, - extraMessage: String = "" + extraMessage: String = "", ) : KevalException( "Invalid expression at position $position in $expression${ if (extraMessage.isNotBlank()) ", $extraMessage" else "" }" ) -/** - * Invalid Operator Exception, is thrown when an invalid/unknown operator is found - * - * @property invalidSymbol is the given invalid operator - * @param expression is the invalid expression - * @param position is the estimated position of the error - */ +internal class KevalMalformedExpressionException internal constructor( + expression: String, + position: Int, + extraMessage: String = "", +) : KevalInvalidExpressionException(expression, position, extraMessage) + class KevalInvalidSymbolException internal constructor( val invalidSymbol: String, expression: String, position: Int, - message: String = "" + message: String = "", ) : KevalInvalidExpressionException(expression, position, message) -/** - * Zero Division Exception, is thrown when a zero division occurs (i.e. x/0, x%0) - */ class KevalZeroDivisionException : KevalException("Division by zero") -/** - * Invalid Argument Exception, is thrown when a given argument to an operator or a function is invalid. - * For example, when a negative number or a non-integer number is given to the factorial function - * - * @param message is the message to display in the stacktrace - */ class KevalInvalidArgumentException(message: String) : KevalException(message) -/** - * DSL Exception, is thrown when a required field isn't defined - * - * @param what is the name of the undefined field - */ +class KevalUnresolvedVariableException(val name: String) : + KevalException("Unresolved variable: $name") + class KevalDSLException internal constructor(what: String) : KevalException("All required fields must be properly defined: $what") diff --git a/src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt b/src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt index 9879c4f..413f687 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt @@ -1,24 +1,64 @@ package com.notkamui.keval /** - * Describes how a numeric type is parsed and which built-in operators, functions, and constants - * are available by default for that type. + * Parses string literals into values of type [N]. */ -interface KevalNumber { +interface KevalLiteralParser { fun isValidLiteral(token: String): Boolean fun parseLiteral(token: String): N +} + +/** + * Provides built-in operators, functions, and constants for a numeric type. + */ +interface KevalDefaults { fun defaultResources(): Map> } +/** + * Describes how a numeric type is parsed, multiplied (for implicit `*`), and which defaults are available. + */ +interface KevalNumber : KevalLiteralParser, KevalDefaults { + /** Used for implicit and explicit multiplication; cannot be overridden by consumers. */ + fun multiply(a: N, b: N): N +} + +typealias KevalReal = Keval + /** * Entry points for built-in numeric type implementations. */ object KevalNumbers { - val Double: KevalNumber = KevalNumberDouble + val real: KevalNumber = KevalNumberDouble + + internal val defaultRealKeval: Keval by lazy { + Keval.create(real) { includeDefault() } + } } /** * Evaluates [expression] using this number type's default resources. */ fun KevalNumber.eval(expression: String): N = - Keval.create(this) { includeDefault() }.eval(expression) + if (this === KevalNumberDouble) { + @Suppress("UNCHECKED_CAST") + KevalNumbers.defaultRealKeval.eval(expression) as N + } else { + Keval.create(this) { includeDefault() }.eval(expression) + } + +/** + * Evaluates [expression] using the given [number] context and optional [configure] block. + */ +fun String.evalWith( + number: KevalNumber, + configure: KevalBuilder.() -> Unit = { includeDefault() }, +): N = Keval.create(number, configure).eval(this) + +/** + * Compiles [expression] using the given [number] context and optional [configure] block. + */ +fun String.compileWith( + number: KevalNumber, + configure: KevalBuilder.() -> Unit = { includeDefault() }, +): CompiledExpression = Keval.create(number, configure).compile(this) diff --git a/src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt b/src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt index cae654d..343c997 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt @@ -8,125 +8,98 @@ object KevalNumberDouble : KevalNumber { override fun parseLiteral(token: String): Double = token.toDouble() - override fun defaultResources(): Map> = mapOf( - // binary operators - "+" to KevalBothOperator( - KevalBinaryOperator(2, true) { a, b -> a + b }, - KevalUnaryOperator(true) { it } - ), - "-" to KevalBothOperator( - KevalBinaryOperator(2, true) { a, b -> a - b }, - KevalUnaryOperator(true) { -it } - ), + override fun multiply(a: Double, b: Double): Double = a * b - "/" to KevalBinaryOperator(3, true) { a, b -> - if (b == 0.0) throw KevalZeroDivisionException() - a / b - }, - "%" to KevalBinaryOperator(3, true) { a, b -> - if (b == 0.0) throw KevalZeroDivisionException() - a % b - }, - "^" to KevalBinaryOperator(4, false) { a, b -> a.pow(b) }, - "*" to KevalBinaryOperator(3, true) { a, b -> a * b }, + override fun defaultResources(): Map> = DEFAULT_RESOURCES - // unary operators - "!" to KevalUnaryOperator(false) { - if (it < 0) throw KevalInvalidArgumentException("factorial of a negative number") - if (floor(it) != it) throw KevalInvalidArgumentException("factorial of a non-integer") - var result = 1.0 - for (i in 2..it.toInt()) { - result *= i - } - result - }, + private val DEFAULT_RESOURCES: Map> = buildDefaultResources() - // functions - "neg" to KevalFunction(1) { -it[0] }, - "sign" to KevalFunction(1) { if (it[0] < 0) -1.0 else if (it[0] > 0) 1.0 else 0.0 }, - "abs" to KevalFunction(1) { it[0].absoluteValue }, - "sqrt" to KevalFunction(1) { sqrt(it[0]) }, - "cbrt" to KevalFunction(1) { cbrt(it[0]) }, - "nthrt" to KevalFunction(2) { it[1].pow(1 / it[0]) }, - "exp" to KevalFunction(1) { exp(it[0]) }, - "ln" to KevalFunction(1) { ln(it[0]) }, - "log10" to KevalFunction(1) { log10(it[0]) }, - "log2" to KevalFunction(1) { log2(it[0]) }, - "sin" to KevalFunction(1) { sin(it[0]) }, - "cos" to KevalFunction(1) { cos(it[0]) }, - "tan" to KevalFunction(1) { tan(it[0]) }, - "asin" to KevalFunction(1) { asin(it[0]) }, - "acos" to KevalFunction(1) { acos(it[0]) }, - "atan" to KevalFunction(1) { atan(it[0]) }, - "ceil" to KevalFunction(1) { ceil(it[0]) }, - "floor" to KevalFunction(1) { floor(it[0]) }, - "round" to KevalFunction(1) { round(it[0]) }, - "trunc" to KevalFunction(1) { it[0].toInt().toDouble() }, - "min" to KevalFunction(null) { it.min() }, - "max" to KevalFunction(null) { it.max() }, - "sum" to KevalFunction(null) { it.sum() }, - "avg" to KevalFunction(null) { it.average() }, - "median" to KevalFunction(null) { it.sorted()[it.size / 2] }, - "percentile" to KevalFunction(null) { - if (it.size <= 1) throw KevalInvalidArgumentException("percentile requires at least 2 values") - val perc = it[0] - if (perc !in 0.0..100.0) throw KevalInvalidArgumentException("percentile must be between 0 and 100") - val sorted = it.sorted() - val index = ((perc / 100) * sorted.size).toInt() - sorted[index] - }, - "rand" to KevalFunction(null) { - when (it.size) { - 0 -> Random.Default.nextDouble() - 1 -> (0..it[0].toInt()).random().toDouble() - else -> it.random() - } - }, - "randRange" to KevalFunction(3) { - val start = it[0] - val end = it[1] - val step = it[2] - - if (step > 0) throw KevalInvalidArgumentException("step must be greater than 0") - val numberOfSteps = ((end - start) / step).toInt() - val randomStepIndex = Random.nextInt(0, numberOfSteps + 1) - start + randomStepIndex * step - }, - - // logical functions - "bool" to KevalFunction(1) { booleanToDouble(it[0] != 0.0) }, - "not" to KevalFunction(1) { booleanToDouble(!doubleToBoolean(it[0])) }, - "and" to KevalFunction(null) { it.reduceBoolean { a, b -> a && b } }, - "nand" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a && b } }, - "or" to KevalFunction(null) { it.reduceBoolean { a, b -> a || b } }, - "nor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a || b } }, - "xor" to KevalFunction(null) { it.reduceBoolean { a, b -> a xor b } }, - "xnor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a xor b } }, - "imply" to KevalFunction(2) { booleanOperation(it) { a, b -> !a || b } }, - "nimply" to KevalFunction(2) { booleanOperation(it) { a, b -> a && !b } }, - "eq" to KevalFunction(null) { booleanToDouble(it.all { e -> e == it[0] }) }, - "ne" to KevalFunction(null) { booleanToDouble(it.distinct().size == it.size) }, - "gt" to KevalFunction(2) { booleanToDouble(it[0] > it[1]) }, - "lt" to KevalFunction(2) { booleanToDouble(it[0] < it[1]) }, - "ge" to KevalFunction(2) { booleanToDouble(it[0] >= it[1]) }, - "le" to KevalFunction(2) { booleanToDouble(it[0] <= it[1]) }, - - // constants - "PI" to KevalConstant(PI), - "e" to KevalConstant(E) - ) - - private fun doubleToBoolean(value: Double) = value != 0.0 - private fun booleanToDouble(value: Boolean) = if (value) 1.0 else 0.0 - private fun booleanOperation(array: List, operation: (Boolean, Boolean) -> Boolean) = - booleanToDouble(operation(doubleToBoolean(array[0]), doubleToBoolean(array[1]))) - private fun List.viaBoolean(operation: List.() -> Boolean) = - booleanToDouble(operation(map(::doubleToBoolean))) - private fun List.reduceBoolean(invert: Boolean = false, operation: (Boolean, Boolean) -> Boolean) = - viaBoolean { - reduce(operation).let { - if (invert) !it - else it - } - } + private fun buildDefaultResources(): Map> { + val logical = BooleanLogic.operators( + isTruthy = { it != 0.0 }, + trueValue = 1.0, + falseValue = 0.0, + compare = { a, b -> a.compareTo(b) }, + ) + return mapOf( + "+" to KevalBothOperator( + KevalBinaryOperator(2, true) { a, b -> a + b }, + KevalUnaryOperator(true) { it } + ), + "-" to KevalBothOperator( + KevalBinaryOperator(2, true) { a, b -> a - b }, + KevalUnaryOperator(true) { -it } + ), + "/" to KevalBinaryOperator(3, true) { a, b -> + if (b == 0.0) throw KevalZeroDivisionException() + a / b + }, + "%" to KevalBinaryOperator(3, true) { a, b -> + if (b == 0.0) throw KevalZeroDivisionException() + a % b + }, + "^" to KevalBinaryOperator(4, false) { a, b -> a.pow(b) }, + "*" to KevalBinaryOperator(3, true) { a, b -> a * b }, + "!" to KevalUnaryOperator(false) { + if (it < 0) throw KevalInvalidArgumentException("factorial of a negative number") + if (floor(it) != it) throw KevalInvalidArgumentException("factorial of a non-integer") + var result = 1.0 + for (i in 2..it.toInt()) { + result *= i + } + result + }, + "neg" to KevalFunction(1) { -it[0] }, + "sign" to KevalFunction(1) { if (it[0] < 0) -1.0 else if (it[0] > 0) 1.0 else 0.0 }, + "abs" to KevalFunction(1) { it[0].absoluteValue }, + "sqrt" to KevalFunction(1) { sqrt(it[0]) }, + "cbrt" to KevalFunction(1) { cbrt(it[0]) }, + "nthrt" to KevalFunction(2) { it[1].pow(1 / it[0]) }, + "exp" to KevalFunction(1) { exp(it[0]) }, + "ln" to KevalFunction(1) { ln(it[0]) }, + "log10" to KevalFunction(1) { log10(it[0]) }, + "log2" to KevalFunction(1) { log2(it[0]) }, + "sin" to KevalFunction(1) { sin(it[0]) }, + "cos" to KevalFunction(1) { cos(it[0]) }, + "tan" to KevalFunction(1) { tan(it[0]) }, + "asin" to KevalFunction(1) { asin(it[0]) }, + "acos" to KevalFunction(1) { acos(it[0]) }, + "atan" to KevalFunction(1) { atan(it[0]) }, + "ceil" to KevalFunction(1) { ceil(it[0]) }, + "floor" to KevalFunction(1) { floor(it[0]) }, + "round" to KevalFunction(1) { round(it[0]) }, + "trunc" to KevalFunction(1) { it[0].toInt().toDouble() }, + "min" to KevalFunction(null) { it.min() }, + "max" to KevalFunction(null) { it.max() }, + "sum" to KevalFunction(null) { it.sum() }, + "avg" to KevalFunction(null) { it.average() }, + "median" to KevalFunction(null) { it.sorted()[it.size / 2] }, + "percentile" to KevalFunction(null) { + if (it.size <= 1) throw KevalInvalidArgumentException("percentile requires at least 2 values") + val perc = it[0] + if (perc !in 0.0..100.0) throw KevalInvalidArgumentException("percentile must be between 0 and 100") + val sorted = it.sorted() + val index = ((perc / 100) * sorted.size).toInt() + sorted[index] + }, + "rand" to KevalFunction(null) { + when (it.size) { + 0 -> Random.Default.nextDouble() + 1 -> (0..it[0].toInt()).random().toDouble() + else -> it.random() + } + }, + "randRange" to KevalFunction(3) { + val start = it[0] + val end = it[1] + val step = it[2] + if (step <= 0) throw KevalInvalidArgumentException("step must be greater than 0") + val numberOfSteps = ((end - start) / step).toInt() + val randomStepIndex = Random.nextInt(0, numberOfSteps + 1) + start + randomStepIndex * step + }, + "PI" to KevalConstant(PI), + "e" to KevalConstant(E), + ) + logical + } } diff --git a/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt b/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt index 55c661e..a9a1886 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt @@ -4,10 +4,17 @@ private enum class TokenType { FIRST, OPERAND, OPERATOR, LPAREN, RPAREN, COMMA, } +private val IDENTIFIER_REGEX = Regex("[a-zA-Z_][a-zA-Z0-9_]*") + +internal fun String.isIdentifierName(): Boolean = + isNotEmpty() && this[0] !in '0'..'9' && IDENTIFIER_REGEX.matches(this) + +private fun String.isVariableOperand(symbols: Map>): Boolean = + isIdentifierName() && this !in symbols + private fun shouldAssumeMul(tokenType: TokenType): Boolean = tokenType == TokenType.OPERAND || tokenType == TokenType.RPAREN -// normalize tokens to be of specific form (add product symbols where they should be assumed) private fun Sequence.normalizeTokens( number: KevalNumber, symbols: Map>, @@ -19,10 +26,11 @@ private fun Sequence.normalizeTokens( val ret = mutableListOf() this.forEach { token -> prevToken = when { - token.isNumeric(number) || symbols[token] is KevalConstant -> TokenType.OPERAND.also { - if (shouldAssumeMul(prevToken)) ret.add("*") - ret.add(token) - } + token.isNumeric(number) || symbols[token] is KevalConstant || token.isVariableOperand(symbols) -> + TokenType.OPERAND.also { + if (shouldAssumeMul(prevToken)) ret.add("*") + ret.add(token) + } token.isKevalOperator(symbols.keys) -> TokenType.OPERATOR.also { if (shouldAssumeMul(prevToken) && (symbols[token] is KevalConstant || symbols[token] is KevalFunction)) { @@ -65,30 +73,11 @@ private fun Sequence.normalizeTokens( return ret } -/** - * Checks if a string is numeric or not - * - * @receiver is the string to check - * @return true if the string is numeric, false otherwise - */ internal fun String.isNumeric(number: KevalNumber): Boolean = number.isValidLiteral(this) -/** - * Checks if a string is a Keval Operator or not - * - * @receiver is the string to check - * @return true if the string is a valid operator, false otherwise - */ internal fun String.isKevalOperator(symbolsSet: Set): Boolean = this in symbolsSet -/** - * Tokenizes a mathematical expression - * - * @receiver is the string to tokenize - * @return the list of tokens - * @throws KevalInvalidSymbolException if the expression contains an invalid symbol - */ internal fun String.tokenize( number: KevalNumber, symbolsSet: Map>, diff --git a/src/commonTest/kotlin/com/notkamui/keval/ASTTest.kt b/src/commonTest/kotlin/com/notkamui/keval/ASTTest.kt index 3789157..3f427a6 100644 --- a/src/commonTest/kotlin/com/notkamui/keval/ASTTest.kt +++ b/src/commonTest/kotlin/com/notkamui/keval/ASTTest.kt @@ -12,7 +12,7 @@ class ASTTest { */ @Test fun simpleEvalTest() { - val operators = KevalNumbers.Double.defaultResources() + val operators = KevalNumbers.real.defaultResources() val plus = (operators["+"] as? KevalBothOperator)!!.binary.implementation val ast: Node = BinaryOperatorNode(ValueNode(3.0), plus, ValueNode(2.0)) assertEquals(ast.eval(), 5.0) diff --git a/src/commonTest/kotlin/com/notkamui/keval/CompiledExpressionTest.kt b/src/commonTest/kotlin/com/notkamui/keval/CompiledExpressionTest.kt new file mode 100644 index 0000000..a978689 --- /dev/null +++ b/src/commonTest/kotlin/com/notkamui/keval/CompiledExpressionTest.kt @@ -0,0 +1,39 @@ +package com.notkamui.keval + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame + +class CompiledExpressionTest { + + private val keval = Keval.create(KevalNumbers.real) { includeDefault() } + + @Test + fun compileOnceEvalManyTimes() { + val compiled = keval.compile("2 + 3 * 4") + assertEquals(14.0, compiled.eval()) + assertEquals(14.0, compiled.eval()) + } + + @Test + fun compiledExpressionWithBindings() { + val compiled = keval.compile("base * rate") + assertEquals(50.0, compiled.eval(mapOf("base" to 10.0, "rate" to 5.0))) + assertEquals(20.0, compiled.eval(mapOf("base" to 4.0, "rate" to 5.0))) + } + + @Test + fun compileIsDistinctFromEvalEachTime() { + val compiled = keval.compile("1 + 1") + val direct = keval.eval("1 + 1") + assertEquals(direct, compiled.eval()) + assertNotSame(compiled, keval.compile("1 + 1")) + } + + @Test + fun compiledEvalOrNullAndResult() { + val compiled = keval.compile("missing + 1") + assertEquals(null, compiled.evalOrNull()) + assertEquals(true, compiled.evalResult().isFailure) + } +} diff --git a/src/commonTest/kotlin/com/notkamui/keval/DSLResourcesTests.kt b/src/commonTest/kotlin/com/notkamui/keval/DSLResourcesTests.kt index ee024d3..0d0dd4e 100644 --- a/src/commonTest/kotlin/com/notkamui/keval/DSLResourcesTests.kt +++ b/src/commonTest/kotlin/com/notkamui/keval/DSLResourcesTests.kt @@ -12,7 +12,7 @@ fun hypotenuse(x: Double, y: Double): Double = sqrt(x*x + y*y) class DLSTest { @Test fun checkSimpleDLS() { - val kvl = Keval.create(KevalNumbers.Double) { + val kvl = Keval.create(KevalNumbers.real) { binaryOperator { symbol = ';' implementation = ::hypotenuse @@ -42,7 +42,7 @@ class DLSTest { @Test fun checkCombinedDSL() { - val kvl = Keval.create(KevalNumbers.Double) { + val kvl = Keval.create(KevalNumbers.real) { includeDefault() binaryOperator { symbol = ';' @@ -84,7 +84,7 @@ class DLSTest { @Test fun conflictTests() { - val kvl = Keval.create(KevalNumbers.Double) { + val kvl = Keval.create(KevalNumbers.real) { function { name = "a" arity = 1 @@ -106,7 +106,7 @@ class DLSTest { @Test fun checkWith() { - val kvl = Keval.create(KevalNumbers.Double) + val kvl = Keval.create(KevalNumbers.real) .withDefault() .withBinaryOperator( ';', @@ -152,7 +152,7 @@ class DLSTest { @Test fun checkOrder() { - val k = Keval.create(KevalNumbers.Double) { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "first" @@ -183,7 +183,7 @@ class DLSTest { @Test fun checkCoherence() { - val k = Keval.create(KevalNumbers.Double) { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "if" @@ -198,7 +198,7 @@ class DLSTest { @Test fun checkRepeatingParentheses() { - val k = Keval.create(KevalNumbers.Double) { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "f" @@ -211,7 +211,7 @@ class DLSTest { @Test fun checkFlexibleArity() { - val k = Keval.create(KevalNumbers.Double) { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "sum" @@ -223,7 +223,7 @@ class DLSTest { @Test fun checkFlexibleArityWithZeroArgs() { - val k = Keval.create(KevalNumbers.Double) { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "sum" @@ -236,7 +236,7 @@ class DLSTest { @Test fun checkOverrideAnOperatorShouldNotFail() { - val k = Keval.create(KevalNumbers.Double) { + val k = Keval.create(KevalNumbers.real) { includeDefault() binaryOperator { symbol = '+' @@ -248,10 +248,21 @@ class DLSTest { assertEquals(3.0, k.eval("1+2"), "1+2") } + @Test + fun randRangeRequiresPositiveStep() { + val k = Keval.create(KevalNumbers.real) { includeDefault() } + assertFailsWith { + k.eval("randRange(0, 10, 0)") + } + assertFailsWith { + k.eval("randRange(0, 10, -1)") + } + } + // this test fails due to wrong handling of nested calls @Test fun checkLogicalOperations() { - val k = Keval.create(KevalNumbers.Double) { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "isPositive" diff --git a/src/commonTest/kotlin/com/notkamui/keval/GrammarTest.kt b/src/commonTest/kotlin/com/notkamui/keval/GrammarTest.kt index f36d4e3..7133f8b 100644 --- a/src/commonTest/kotlin/com/notkamui/keval/GrammarTest.kt +++ b/src/commonTest/kotlin/com/notkamui/keval/GrammarTest.kt @@ -14,8 +14,8 @@ class GrammarTest { */ @Test fun grammarTest() { - val operators = KevalNumbers.Double.defaultResources() - assertEquals(8.0, "3 + 5 * (2-1)".toAST(KevalNumbers.Double, operators).eval()) + val operators = KevalNumbers.real.defaultResources() + assertEquals(8.0, "3 + 5 * (2-1)".toAST(KevalNumbers.real, operators).eval()) } /** diff --git a/src/commonTest/kotlin/com/notkamui/keval/TokenizerTest.kt b/src/commonTest/kotlin/com/notkamui/keval/TokenizerTest.kt index 6eb2abc..0b0cbe4 100644 --- a/src/commonTest/kotlin/com/notkamui/keval/TokenizerTest.kt +++ b/src/commonTest/kotlin/com/notkamui/keval/TokenizerTest.kt @@ -14,7 +14,7 @@ class TokenizerTest { */ @Test fun parseString() { - val operators = KevalNumbers.Double.defaultResources().plus( + val operators = KevalNumbers.real.defaultResources().plus( listOf( "A_1a2b3c" to KevalConstant(1.2), "A_a1b2c3" to KevalConstant(2.3), @@ -25,7 +25,7 @@ class TokenizerTest { "__A" to KevalConstant(7.8), ) ) - val tokens = "((34+8)/3)+3.3*(5+2)%2^6+A_1a2b3c^4.2+A_a1b2c3/4.2-A1_1A_A1_AB_AB_12%4.2+A__B-A__+_A-__A".tokenize(KevalNumbers.Double, operators) + val tokens = "((34+8)/3)+3.3*(5+2)%2^6+A_1a2b3c^4.2+A_a1b2c3/4.2-A1_1A_A1_AB_AB_12%4.2+A__B-A__+_A-__A".tokenize(KevalNumbers.real, operators) assertEquals( listOf( "(", @@ -73,25 +73,21 @@ class TokenizerTest { tokens ) - val tokens2 = "(3+4 ) (2-5) ".tokenize(KevalNumbers.Double, operators) // check auto mul + val tokens2 = "(3+4 ) (2-5) ".tokenize(KevalNumbers.real, operators) // check auto mul assertEquals( listOf("(", "3", "+", "4", ")", "*", "(", "2", "-", "5", ")"), tokens2 ) - assertTrue { - try { - "(37+4)a+5".tokenize(KevalNumbers.Double, operators) - false - } catch (e: KevalInvalidSymbolException) { - e.invalidSymbol == "a" && e.position == 6 && e.expression == "(37+4)a+5" - } - } + assertEquals( + listOf("(", "37", "+", "4", ")", "*", "a", "+", "5"), + "(37+4)a+5".tokenize(KevalNumbers.real, operators), + ) } @Test fun checkRepeatingParentheses() { - val k = Keval.create(KevalNumbers.Double) { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "f" @@ -100,13 +96,13 @@ class TokenizerTest { } } - val nodes = "f(((1)))".tokenize(KevalNumbers.Double, k.resourcesView()) + val nodes = "f(((1)))".tokenize(KevalNumbers.real, k.resourcesView()) assertEquals("f(((1)))", nodes.joinToString(separator = "")) } @Test fun checkNestedFunctions() { - val k = Keval.create(KevalNumbers.Double) { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "f" @@ -124,7 +120,7 @@ class TokenizerTest { } } - val nodes = "f(s(a(1,2),3))".tokenize(KevalNumbers.Double, k.resourcesView()) + val nodes = "f(s(a(1,2),3))".tokenize(KevalNumbers.real, k.resourcesView()) assertEquals("f(s(a(1,2),3))", nodes.joinToString(separator = "")) } } diff --git a/src/commonTest/kotlin/com/notkamui/keval/VariableTest.kt b/src/commonTest/kotlin/com/notkamui/keval/VariableTest.kt new file mode 100644 index 0000000..3f59522 --- /dev/null +++ b/src/commonTest/kotlin/com/notkamui/keval/VariableTest.kt @@ -0,0 +1,80 @@ +package com.notkamui.keval + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class VariableTest { + + private val keval = Keval.create(KevalNumbers.real) { includeDefault() } + + @Test + fun evalWithBindings() { + assertEquals(10.0, keval.eval("x + y", mapOf("x" to 3.0, "y" to 7.0))) + } + + @Test + fun unresolvedVariableThrows() { + assertFailsWith { + keval.eval("x + 1") + } + } + + @Test + fun missingBindingThrows() { + assertFailsWith { + keval.eval("x + y", mapOf("x" to 1.0)) + } + } + + @Test + fun implicitMultiplicationWithVariable() { + assertEquals(6.0, keval.eval("x(y + 1)", mapOf("x" to 2.0, "y" to 2.0))) + assertEquals(6.0, keval.eval("2 x", mapOf("x" to 3.0))) + } + + @Test + fun compileCollectsVariables() { + val compiled = keval.compile("x * y + z") + assertEquals(setOf("x", "y", "z"), compiled.variables) + assertEquals(20.0, compiled.eval(mapOf("x" to 2.0, "y" to 5.0, "z" to 10.0))) + } + + @Test + fun constantTakesPrecedenceOverVariableName() { + val custom = Keval.create(KevalNumbers.real) { + includeDefault() + constant { + name = "x" + value = 99.0 + } + } + assertEquals(100.0, custom.eval("x + 1")) + } + + @Test + fun evalWithExtension() { + assertEquals(5.0, "2 + 3".evalWith(KevalNumbers.real)) + } + + @Test + fun compileWithExtension() { + val compiled = "a * 2".compileWith(KevalNumbers.real) + assertEquals(setOf("a"), compiled.variables) + assertEquals(8.0, compiled.eval(mapOf("a" to 4.0))) + } + + @Test + fun evalOrNullReturnsNullOnError() { + assertEquals(null, keval.evalOrNull("x + 1")) + assertEquals(3.0, keval.evalOrNull("1 + 2")) + } + + @Test + fun evalResultCapturesException() { + val result = keval.evalResult("x + 1") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is KevalUnresolvedVariableException) + } +} diff --git a/src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt b/src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt index a153f9e..8af45d5 100644 --- a/src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt +++ b/src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt @@ -4,8 +4,9 @@ import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode -object KevalNumberBigDecimal : KevalNumber { - val mathContext: MathContext = MathContext.DECIMAL128 +class KevalNumberBigDecimal private constructor( + val mathContext: MathContext, +) : KevalNumber { private val ZERO = BigDecimal.ZERO private val ONE = BigDecimal.ONE @@ -20,98 +21,99 @@ object KevalNumberBigDecimal : KevalNumber { override fun parseLiteral(token: String): BigDecimal = BigDecimal(token) - override fun defaultResources(): Map> = mapOf( - "+" to KevalBothOperator( - KevalBinaryOperator(2, true) { a, b -> a.add(b) }, - KevalUnaryOperator(true) { it } - ), - "-" to KevalBothOperator( - KevalBinaryOperator(2, true) { a, b -> a.subtract(b) }, - KevalUnaryOperator(true) { it.negate() } - ), - "/" to KevalBinaryOperator(3, true) { a, b -> - if (b.compareTo(ZERO) == 0) throw KevalZeroDivisionException() - a.divide(b, mathContext) - }, - "%" to KevalBinaryOperator(3, true) { a, b -> - if (b.compareTo(ZERO) == 0) throw KevalZeroDivisionException() - a.remainder(b) - }, - "^" to KevalBinaryOperator(4, false) { a, b -> - if (b.stripTrailingZeros().scale() > 0) { - throw KevalInvalidArgumentException("non-integer exponent") - } - val exp = b.intValueExact() - when { - exp >= 0 -> a.pow(exp) - a.compareTo(ZERO) == 0 -> throw KevalInvalidArgumentException("zero to a negative power") - else -> ONE.divide(a.pow(-exp), mathContext) - } - }, - "*" to KevalBinaryOperator(3, true) { a, b -> a.multiply(b) }, - - "neg" to KevalFunction(1) { it[0].negate() }, - "abs" to KevalFunction(1) { it[0].abs() }, - "sign" to KevalFunction(1) { - when (it[0].compareTo(ZERO)) { - -1 -> NEG_ONE - 1 -> ONE - else -> ZERO - } - }, - "min" to KevalFunction(null) { args -> args.minWithOrNull(compareBy { it })!! }, - "max" to KevalFunction(null) { args -> args.maxWithOrNull(compareBy { it })!! }, - "sum" to KevalFunction(null) { args -> args.fold(ZERO, BigDecimal::add) }, - "avg" to KevalFunction(null) { args -> - args.fold(ZERO, BigDecimal::add) - .divide(BigDecimal.valueOf(args.size.toLong()), mathContext) - }, - "ceil" to KevalFunction(1) { it[0].setScale(0, RoundingMode.CEILING) }, - "floor" to KevalFunction(1) { it[0].setScale(0, RoundingMode.FLOOR) }, - "round" to KevalFunction(1) { it[0].setScale(0, RoundingMode.HALF_UP) }, - "trunc" to KevalFunction(1) { it[0].setScale(0, RoundingMode.DOWN) }, - - "bool" to KevalFunction(1) { booleanToDecimal(isTruthy(it[0])) }, - "not" to KevalFunction(1) { booleanToDecimal(!isTruthy(it[0])) }, - "and" to KevalFunction(null) { it.reduceBoolean { a, b -> a && b } }, - "nand" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a && b } }, - "or" to KevalFunction(null) { it.reduceBoolean { a, b -> a || b } }, - "nor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a || b } }, - "xor" to KevalFunction(null) { it.reduceBoolean { a, b -> a xor b } }, - "xnor" to KevalFunction(null) { it.reduceBoolean(true) { a, b -> a xor b } }, - "imply" to KevalFunction(2) { booleanOperation(it) { a, b -> !a || b } }, - "nimply" to KevalFunction(2) { booleanOperation(it) { a, b -> a && !b } }, - "eq" to KevalFunction(null) { booleanToDecimal(it.all { e -> e.compareTo(it[0]) == 0 }) }, - "ne" to KevalFunction(null) { - booleanToDecimal(it.map { e -> e.stripTrailingZeros() }.distinct().size == it.size) - }, - "gt" to KevalFunction(2) { booleanToDecimal(it[0].compareTo(it[1]) > 0) }, - "lt" to KevalFunction(2) { booleanToDecimal(it[0].compareTo(it[1]) < 0) }, - "ge" to KevalFunction(2) { booleanToDecimal(it[0].compareTo(it[1]) >= 0) }, - "le" to KevalFunction(2) { booleanToDecimal(it[0].compareTo(it[1]) <= 0) }, - ) - - private fun isTruthy(value: BigDecimal) = value.compareTo(ZERO) != 0 - private fun booleanToDecimal(value: Boolean) = if (value) ONE else ZERO - private fun booleanOperation(array: List, operation: (Boolean, Boolean) -> Boolean) = - booleanToDecimal(operation(isTruthy(array[0]), isTruthy(array[1]))) - private fun List.viaBoolean(operation: List.() -> Boolean) = - booleanToDecimal(operation(map(::isTruthy))) - private fun List.reduceBoolean(invert: Boolean = false, operation: (Boolean, Boolean) -> Boolean) = - viaBoolean { - reduce(operation).let { - if (invert) !it - else it - } - } + override fun multiply(a: BigDecimal, b: BigDecimal): BigDecimal = a.multiply(b) + + override fun defaultResources(): Map> = defaultResources + + private val defaultResources: Map> by lazy { buildDefaultResources() } + + private fun buildDefaultResources(): Map> { + val logical = BooleanLogic.operators( + isTruthy = { it.compareTo(ZERO) != 0 }, + trueValue = ONE, + falseValue = ZERO, + compare = { a, b -> a.compareTo(b) }, + ) + val arithmetic = mapOf>( + "+" to KevalBothOperator( + KevalBinaryOperator(2, true) { a, b -> a.add(b) }, + KevalUnaryOperator(true) { it } + ), + "-" to KevalBothOperator( + KevalBinaryOperator(2, true) { a, b -> a.subtract(b) }, + KevalUnaryOperator(true) { it.negate() } + ), + "/" to KevalBinaryOperator(3, true) { a, b -> + if (b.compareTo(ZERO) == 0) throw KevalZeroDivisionException() + a.divide(b, mathContext) + }, + "%" to KevalBinaryOperator(3, true) { a, b -> + if (b.compareTo(ZERO) == 0) throw KevalZeroDivisionException() + a.remainder(b) + }, + "^" to KevalBinaryOperator(4, false) { a, b -> + if (b.stripTrailingZeros().scale() > 0) { + throw KevalInvalidArgumentException("non-integer exponent") + } + val exp = b.intValueExact() + when { + exp >= 0 -> a.pow(exp) + a.compareTo(ZERO) == 0 -> throw KevalInvalidArgumentException("zero to a negative power") + else -> ONE.divide(a.pow(-exp), mathContext) + } + }, + "*" to KevalBinaryOperator(3, true) { a, b -> a.multiply(b) }, + "neg" to KevalFunction(1) { it[0].negate() }, + "abs" to KevalFunction(1) { it[0].abs() }, + "sign" to KevalFunction(1) { + when (it[0].compareTo(ZERO)) { + -1 -> NEG_ONE + 1 -> ONE + else -> ZERO + } + }, + "min" to KevalFunction(null) { args -> args.minWithOrNull(compareBy { it })!! }, + "max" to KevalFunction(null) { args -> args.maxWithOrNull(compareBy { it })!! }, + "sum" to KevalFunction(null) { args -> args.fold(ZERO, BigDecimal::add) }, + "avg" to KevalFunction(null) { args -> + args.fold(ZERO, BigDecimal::add) + .divide(BigDecimal.valueOf(args.size.toLong()), mathContext) + }, + "ceil" to KevalFunction(1) { it[0].setScale(0, RoundingMode.CEILING) }, + "floor" to KevalFunction(1) { it[0].setScale(0, RoundingMode.FLOOR) }, + "round" to KevalFunction(1) { it[0].setScale(0, RoundingMode.HALF_UP) }, + "trunc" to KevalFunction(1) { it[0].setScale(0, RoundingMode.DOWN) }, + ) + return arithmetic + logical + } + + companion object { + val Default: KevalNumberBigDecimal = KevalNumberBigDecimal(MathContext.DECIMAL128) + + fun withContext(context: MathContext): KevalNumberBigDecimal = KevalNumberBigDecimal(context) + } } val KevalNumbers.BigDecimal: KevalNumber - get() = KevalNumberBigDecimal + get() = KevalNumberBigDecimal.Default + +private val defaultBigDecimalKeval: Keval by lazy { + Keval.create(KevalNumbers.BigDecimal) { includeDefault() } +} -/** - * Evaluates a mathematical expression using default BigDecimal resources. - */ fun String.kevalBigDecimal( generator: KevalBuilder.() -> Unit = { includeDefault() } -): BigDecimal = Keval.create(KevalNumberBigDecimal, generator).eval(this) +): BigDecimal = Keval.create(KevalNumbers.BigDecimal, generator).eval(this) + +fun String.kevalBigDecimal( + bindings: Map, + generator: KevalBuilder.() -> Unit = { includeDefault() }, +): BigDecimal = Keval.create(KevalNumbers.BigDecimal, generator).eval(this, bindings) + +fun String.kevalBigDecimalOrNull(): BigDecimal? = defaultBigDecimalKeval.evalOrNull(this) + +fun String.kevalBigDecimalResult(): Result = defaultBigDecimalKeval.evalResult(this) + +fun String.compileBigDecimal( + generator: KevalBuilder.() -> Unit = { includeDefault() }, +): CompiledExpression = Keval.create(KevalNumbers.BigDecimal, generator).compile(this) diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt index 984662e..c7c173c 100644 --- a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt @@ -11,7 +11,7 @@ class KevalBigDecimalBuilderTest { @Test fun kevalNumbersBigDecimalEntryPoint() { - assertEquals(KevalNumberBigDecimal, KevalNumbers.BigDecimal) + assertEquals(KevalNumberBigDecimal.Default, KevalNumbers.BigDecimal) } @Test @@ -100,7 +100,7 @@ class KevalBigDecimalBuilderTest { @Test fun excludedDoubleOnlyFunctionsNotInDefaults() { - val defaults = KevalNumberBigDecimal.defaultResources() + val defaults = KevalNumberBigDecimal.Default.defaultResources() listOf("sin", "cos", "tan", "sqrt", "ln", "exp", "rand", "!", "PI", "e", "median", "percentile") .forEach { name -> assertFalse(name in defaults, "$name should not be in BigDecimal defaults") @@ -109,7 +109,7 @@ class KevalBigDecimalBuilderTest { @Test fun includedFunctionsAreInDefaults() { - val defaults = KevalNumberBigDecimal.defaultResources() + val defaults = KevalNumberBigDecimal.Default.defaultResources() listOf( "+", "-", "*", "/", "%", "^", "neg", "abs", "sign", "min", "max", "sum", "avg", diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalErrorsTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalErrorsTest.kt index 61350d0..59d4e9d 100644 --- a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalErrorsTest.kt +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalErrorsTest.kt @@ -21,7 +21,7 @@ class KevalBigDecimalErrorsTest { @Test fun invalidSymbolThrows() { - assertFailsWith { "1 + a".kevalBigDecimal() } + assertFailsWith { "1 + @".kevalBigDecimal() } } @Test @@ -32,7 +32,7 @@ class KevalBigDecimalErrorsTest { @Test fun unknownFunctionThrows() { - assertFailsWith { "sin(1)".kevalBigDecimal() } + assertFailsWith { "sin(1)".kevalBigDecimal() } } @Test diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalParsingTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalParsingTest.kt index 9215b2a..76e4a85 100644 --- a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalParsingTest.kt +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalParsingTest.kt @@ -34,29 +34,29 @@ class KevalBigDecimalParsingTest { @Test fun isValidLiteralAcceptsNumericForms() { - assertTrue(KevalNumberBigDecimal.isValidLiteral("42")) - assertTrue(KevalNumberBigDecimal.isValidLiteral("3.14")) - assertTrue(KevalNumberBigDecimal.isValidLiteral("1e10")) - assertTrue(KevalNumberBigDecimal.isValidLiteral("-2.5")) + assertTrue(KevalNumbers.BigDecimal.isValidLiteral("42")) + assertTrue(KevalNumbers.BigDecimal.isValidLiteral("3.14")) + assertTrue(KevalNumbers.BigDecimal.isValidLiteral("1e10")) + assertTrue(KevalNumbers.BigDecimal.isValidLiteral("-2.5")) } @Test fun isValidLiteralRejectsNonNumeric() { - assertFalse(KevalNumberBigDecimal.isValidLiteral("abc")) - assertFalse(KevalNumberBigDecimal.isValidLiteral("")) - assertFalse(KevalNumberBigDecimal.isValidLiteral("1..2")) + assertFalse(KevalNumbers.BigDecimal.isValidLiteral("abc")) + assertFalse(KevalNumbers.BigDecimal.isValidLiteral("")) + assertFalse(KevalNumbers.BigDecimal.isValidLiteral("1..2")) } @Test fun parseLiteralMatchesBigDecimalConstructor() { - assertEquals(0, BigDecimal("-123.456").compareTo(KevalNumberBigDecimal.parseLiteral("-123.456"))) + assertEquals(0, BigDecimal("-123.456").compareTo(KevalNumbers.BigDecimal.parseLiteral("-123.456"))) } @Test fun tokenizePreservesScientificLiteral() { val tokens = "1e10 + 2e-3".tokenize( - KevalNumberBigDecimal, - KevalNumberBigDecimal.defaultResources() + KevalNumberBigDecimal.Default, + KevalNumberBigDecimal.Default.defaultResources() ) assertEquals(listOf("1e10", "+", "2e-3"), tokens) } @@ -64,8 +64,8 @@ class KevalBigDecimalParsingTest { @Test fun tokenizeImplicitMultiplication() { val tokens = "(2+3)(4+1)".tokenize( - KevalNumberBigDecimal, - KevalNumberBigDecimal.defaultResources() + KevalNumberBigDecimal.Default, + KevalNumberBigDecimal.Default.defaultResources() ) assertEquals(listOf("(", "2", "+", "3", ")", "*", "(", "4", "+", "1", ")"), tokens) } diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalVariableTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalVariableTest.kt new file mode 100644 index 0000000..b647ba0 --- /dev/null +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalVariableTest.kt @@ -0,0 +1,24 @@ +package com.notkamui.keval + +import java.math.BigDecimal +import java.math.MathContext +import kotlin.test.Test + +class KevalBigDecimalVariableTest { + + @Test + fun variablesWithBindings() { + assertDecimalEquals( + "15", + Keval.create(KevalNumbers.BigDecimal) { includeDefault() } + .eval("price * qty", mapOf("price" to BigDecimal("3"), "qty" to BigDecimal("5"))) + ) + } + + @Test + fun customMathContext() { + val lowPrecision = KevalNumberBigDecimal.withContext(MathContext(4)) + val result = Keval.create(lowPrecision) { includeDefault() }.eval("1 / 3") + assertDecimalEquals("0.3333", result) + } +} From 8e327ec9388c159505d95b6b6d8fcda22aed162b Mon Sep 17 00:00:00 2001 From: notKamui <46132319+notKamui@users.noreply.github.com> Date: Sat, 23 May 2026 19:13:24 +0200 Subject: [PATCH 4/4] fix unresolved --- .../kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt index c7c173c..5156d5d 100644 --- a/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt +++ b/src/jvmTest/kotlin/com/notkamui/keval/KevalBigDecimalBuilderTest.kt @@ -60,7 +60,7 @@ class KevalBigDecimalBuilderTest { function { name = "double" arity = 1 - implementation = { it[0].multiply(BigDecimal.TWO) } + implementation = { it[0].multiply(BigDecimal(2)) } } } assertEquals(BigDecimal("42"), kvl.eval("double(21)")) @@ -82,7 +82,7 @@ class KevalBigDecimalBuilderTest { fun withMethodsChain() { val kvl = Keval.create(KevalNumbers.BigDecimal) { includeDefault() } .withConstant("PHI", BigDecimal("1.618")) - .withFunction("twice", 1) { it[0].multiply(BigDecimal.TWO) } + .withFunction("twice", 1) { it[0].multiply(BigDecimal(2)) } assertDecimalEquals("3.236", kvl.eval("twice(PHI)")) }