diff --git a/CHANGELOG.md b/CHANGELOG.md index 0923456..5ddab40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +## [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. +- 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`). +- 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 + +#### 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. +- 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.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.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 7149d67..fe03fdd 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.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,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.real) { includeDefault() } .withBinaryOperator( // includes a new binary operator ';', // symbol 3, // precedence @@ -261,6 +260,86 @@ 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`). 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 +Keval.create(myNumber) { + includeDefault() + function { + name = "twice" + arity = 1 + 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). 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") + +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.real) { includeDefault() }` | +| `(Double, Double) -> Double` operators | `(N, N) -> N` | +| `(DoubleArray) -> Double` functions | `(List) -> N` | +| `KevalBuilder.DEFAULT_RESOURCES` | `KevalNumbers.real.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: @@ -268,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/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..7662a7c 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/AbstractSyntaxTree.kt @@ -3,104 +3,139 @@ 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 - * - * @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( +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 - * - * @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 { - /** - * Evaluates the value of this node - * - * @return the value of the node - * @throws KevalZeroDivisionException in case of a zero division - */ - fun eval(): Double +internal interface Node { + 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: (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(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: (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(bindings: Map): N = op(child.eval(bindings)) + override fun collectVariables(): Set = child.collectVariables() } -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(bindings: Map): N = func(children.map { it.eval(bindings) }) + override fun collectVariables(): Set = children.flatMap { it.collectVariables() }.toSet() } -/** - * A value node (leaf) - * - * @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 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() +} + +internal data class ValueNode( + private val value: N +) : Node { + 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 ece5c2e..bb5f2b1 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/Grammar.kt @@ -1,23 +1,23 @@ 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 - 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" @@ -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,22 +80,22 @@ 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) { - throw KevalInvalidExpressionException( + throw KevalMalformedExpressionException( tokensToString, currentPos, "expected ${op.arity} ${"argument".pluralize(op.arity)} but found ${args.size}", @@ -107,22 +107,22 @@ 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 { + 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,22 +148,25 @@ internal class Parser( } } val token = currentToken - if (!token.isDouble()) { - throw KevalInvalidExpressionException( - tokensToString, - currentPos, - "expected number or symbol but found $token", - ) + if (number.isValidLiteral(token)) { + consume(currentToken) + return ValueNode(number.parseLiteral(token)) } - consume(currentToken) - val node = ValueNode(token.toDouble()) - return node + if (token.isIdentifierName()) { + consume(currentToken) + return VariableNode(token) + } + throw KevalMalformedExpressionException( + tokensToString, + currentPos, + "expected number or symbol but found $token", + ) } - fun parse(): Node { + fun parse(): Node { val node = expression() if (currentTokenOrNull != null) { - throw KevalInvalidExpressionException( + throw KevalMalformedExpressionException( tokensToString, currentPos, "unexpected token $currentTokenOrNull" @@ -180,15 +183,18 @@ 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(operators: Map): Node { +internal fun String.toAST( + number: KevalNumber, + operators: Map>, +): Node { if (this.replace("""[()]""".toRegex(), "").isBlank()) - throw KevalInvalidExpressionException("", -1) + throw KevalMalformedExpressionException("", -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..087d257 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/Keval.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/Keval.kt @@ -7,24 +7,29 @@ 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>, + private val operators: Map>, +) { + + internal constructor( + number: KevalNumber, + resources: Map>, + ) : this( + number = number, + resources = resources, + operators = resources + ( + "*" to KevalBinaryOperator(3, true) { a, b -> number.multiply(a, b) } + ), + ) - /** - * 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. - */ fun withBinaryOperator( symbol: Char, precedence: Int, isLeftAssociative: Boolean, - implementation: (Double, Double) -> Double - ): Keval = KevalBuilder(resources) + implementation: (N, N) -> N + ): Keval = KevalBuilder(number, resources) .binaryOperator { this.symbol = symbol this.precedence = precedence @@ -33,21 +38,11 @@ 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 @@ -55,20 +50,11 @@ 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 @@ -76,106 +62,69 @@ class Keval internal constructor(private val resources: Map = KevalBuilder(number, resources) .constant { this.name = name this.value = value } .build() - /** - * Adds the default resources to this Keval instance. - * - * @return This Keval instance. - */ - fun withDefault(): Keval = KevalBuilder(resources).includeDefault().build() + 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, - ): Double { - val operators = resourcesView() - return mathExpression.toAST(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 KevalBinaryOperator(3, true) { a, b -> a * b }) + fun resourcesView(): Map> = operators companion object { - - /** - * Creates a new instance of [Keval] with the provided 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() - - /** - * Evaluates a mathematical expression using the default 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. - */ + fun create( + number: KevalNumber, + generator: KevalBuilder.() -> Unit = { includeDefault() } + ): Keval = + KevalBuilder(number).apply(generator).build() + @JvmName("evaluate") @JvmStatic - fun eval( - mathExpression: String, - ): Double = mathExpression.toAST(KevalBuilder.DEFAULT_RESOURCES).eval() + 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 = KevalBuilder().apply(generator).build().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 = Keval.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/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/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 new file mode 100644 index 0000000..413f687 --- /dev/null +++ b/src/commonMain/kotlin/com/notkamui/keval/KevalNumber.kt @@ -0,0 +1,64 @@ +package com.notkamui.keval + +/** + * Parses string literals into values of type [N]. + */ +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 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 = + 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 new file mode 100644 index 0000000..343c997 --- /dev/null +++ b/src/commonMain/kotlin/com/notkamui/keval/KevalNumberDouble.kt @@ -0,0 +1,105 @@ +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 multiply(a: Double, b: Double): Double = a * b + + override fun defaultResources(): Map> = DEFAULT_RESOURCES + + private val DEFAULT_RESOURCES: Map> = buildDefaultResources() + + 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 fd25330..a9a1886 100644 --- a/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt +++ b/src/commonMain/kotlin/com/notkamui/keval/Tokenizer.kt @@ -4,11 +4,21 @@ 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(symbols: Map): List { +private fun Sequence.normalizeTokens( + number: KevalNumber, + symbols: Map>, +): List { var currentPos = 0 var prevToken = TokenType.FIRST var parenthesesCount = 0 @@ -16,10 +26,11 @@ private fun Sequence.normalizeTokens(symbols: Map val ret = mutableListOf() this.forEach { token -> prevToken = when { - token.isNumeric() || 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)) { @@ -62,39 +73,22 @@ private fun Sequence.normalizeTokens(symbols: Map 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(): 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 - * - * @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(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..3f427a6 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.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 ac90513..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 { + val kvl = Keval.create(KevalNumbers.real) { binaryOperator { symbol = ';' implementation = ::hypotenuse @@ -42,7 +42,7 @@ class DLSTest { @Test fun checkCombinedDSL() { - val kvl = Keval.create { + val kvl = Keval.create(KevalNumbers.real) { includeDefault() binaryOperator { symbol = ';' @@ -84,7 +84,7 @@ class DLSTest { @Test fun conflictTests() { - val kvl = Keval.create { + val kvl = Keval.create(KevalNumbers.real) { function { name = "a" arity = 1 @@ -106,7 +106,7 @@ class DLSTest { @Test fun checkWith() { - val kvl = Keval.create() + val kvl = Keval.create(KevalNumbers.real) .withDefault() .withBinaryOperator( ';', @@ -152,7 +152,7 @@ class DLSTest { @Test fun checkOrder() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "first" @@ -183,7 +183,7 @@ class DLSTest { @Test fun checkCoherence() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "if" @@ -198,7 +198,7 @@ class DLSTest { @Test fun checkRepeatingParentheses() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "f" @@ -211,7 +211,7 @@ class DLSTest { @Test fun checkFlexibleArity() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "sum" @@ -223,7 +223,7 @@ class DLSTest { @Test fun checkFlexibleArityWithZeroArgs() { - val k = Keval.create { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "sum" @@ -236,7 +236,7 @@ class DLSTest { @Test fun checkOverrideAnOperatorShouldNotFail() { - val k = Keval.create { + 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 { + 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 c6b44e4..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 = KevalBuilder.DEFAULT_RESOURCES - assertEquals(8.0, "3 + 5 * (2-1)".toAST(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 d92c930..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 = KevalBuilder.DEFAULT_RESOURCES.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(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(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(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 { + val k = Keval.create(KevalNumbers.real) { includeDefault() function { name = "f" @@ -100,13 +96,13 @@ class TokenizerTest { } } - val nodes = "f(((1)))".tokenize(k.resourcesView()) + val nodes = "f(((1)))".tokenize(KevalNumbers.real, k.resourcesView()) assertEquals("f(((1)))", nodes.joinToString(separator = "")) } @Test fun checkNestedFunctions() { - val k = Keval.create { + 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(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 new file mode 100644 index 0000000..8af45d5 --- /dev/null +++ b/src/jvmMain/kotlin/com/notkamui/keval/KevalNumberBigDecimal.kt @@ -0,0 +1,119 @@ +package com.notkamui.keval + +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode + +class KevalNumberBigDecimal private constructor( + val mathContext: MathContext, +) : KevalNumber { + + 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 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.Default + +private val defaultBigDecimalKeval: Keval by lazy { + Keval.create(KevalNumbers.BigDecimal) { includeDefault() } +} + +fun String.kevalBigDecimal( + generator: KevalBuilder.() -> Unit = { includeDefault() } +): 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/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..5156d5d --- /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.Default, 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(2)) } + } + } + 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(2)) } + + 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.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") + } + } + + @Test + fun includedFunctionsAreInDefaults() { + val defaults = KevalNumberBigDecimal.Default.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..59d4e9d --- /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 + @".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..76e4a85 --- /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(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(KevalNumbers.BigDecimal.isValidLiteral("abc")) + assertFalse(KevalNumbers.BigDecimal.isValidLiteral("")) + assertFalse(KevalNumbers.BigDecimal.isValidLiteral("1..2")) + } + + @Test + fun parseLiteralMatchesBigDecimalConstructor() { + assertEquals(0, BigDecimal("-123.456").compareTo(KevalNumbers.BigDecimal.parseLiteral("-123.456"))) + } + + @Test + fun tokenizePreservesScientificLiteral() { + val tokens = "1e10 + 2e-3".tokenize( + KevalNumberBigDecimal.Default, + KevalNumberBigDecimal.Default.defaultResources() + ) + assertEquals(listOf("1e10", "+", "2e-3"), tokens) + } + + @Test + fun tokenizeImplicitMultiplication() { + val tokens = "(2+3)(4+1)".tokenize( + KevalNumberBigDecimal.Default, + KevalNumberBigDecimal.Default.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() 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) + } +}