diff --git a/docs/Decimal.md b/docs/Decimal.md new file mode 100644 index 0000000..a443ba1 --- /dev/null +++ b/docs/Decimal.md @@ -0,0 +1,250 @@ +# Decimal (`lyng.decimal`) + +`lyng.decimal` adds an arbitrary-precision decimal type to Lyng as a normal library module. + +Import it when you need decimal arithmetic that should not inherit `Real`'s binary floating-point behavior: + +```lyng +import lyng.decimal +``` + +## What `BigDecimal` Is For + +Use `BigDecimal` when values are fundamentally decimal: + +- money +- human-entered quantities +- exact decimal text +- predictable decimal rounding +- user-facing formatting and tests + +Do not use it just because a number has a fractional part. `Real` is still the right type for ordinary double-precision numeric work. + +## Creating Decimal Values + +There are three supported conversions: + +```lyng +import lyng.decimal + +val a = 1.d +val b = 2.2.d +val c = "2.2".d + +assertEquals("1", a.toStringExpanded()) +assertEquals("2.2", b.toStringExpanded()) +assertEquals("2.2", c.toStringExpanded()) +``` + +The three forms mean different things: + +- `1.d`: convert `Int -> BigDecimal` +- `2.2.d`: convert `Real -> BigDecimal` +- `"2.2".d`: parse exact decimal text + +That distinction is intentional. + +### `Real.d` vs `"..." .d` + +`Real.d` preserves the current `Real` value. It does not pretend the source code was exact decimal text. + +```lyng +import lyng.decimal + +assertEquals("0.30000000000000004", (0.1 + 0.2).d.toStringExpanded()) +assertEquals("0.3", "0.3".d.toStringExpanded()) +``` + +This follows the "minimal confusion" rule: + +- if you start from a `Real`, you get a decimal representation of that `Real` +- if you want exact decimal source text, use a `String` + +## Factory Functions + +The explicit factory methods are: + +```lyng +import lyng.decimal + +BigDecimal.fromInt(10) +BigDecimal.fromReal(2.5) +BigDecimal.fromString("12.34") +``` + +These are equivalent to the conversion-property forms, but sometimes clearer in APIs or generated code. + +## Core Operations + +`BigDecimal` supports: + +- `+` +- `-` +- `*` +- `/` +- `%` +- unary `-` +- comparison operators +- equality operators + +Examples: + +```lyng +import lyng.decimal + +assertEquals("3.75", ("1.5".d + "2.25".d).toStringExpanded()) +assertEquals("1.25", ("2.5".d - "1.25".d).toStringExpanded()) +assertEquals("3.0", ("1.5".d * 2.d).toStringExpanded()) +assertEquals("0.5", (1.d / 2.d).toStringExpanded()) +assert("2.0".d > "1.5".d) +assert("2.0".d == 2.d) +``` + +## Interoperability With `Int` and `Real` + +The decimal module registers mixed-operand operator bridges so both sides read naturally: + +```lyng +import lyng.decimal + +assertEquals(3.d, 1 + 2.d) +assertEquals(3.d, 2.d + 1) +assertEquals(1.5.d, 1.d + 0.5) +assertEquals(1.5.d, 0.5 + 1.d) +assert(2 == 2.d) +assert(3 > 2.d) +``` + +Without this registration mechanism, only the cases directly implemented on the left-hand class would work. The bridge fills the gap for expressions such as `Int + BigDecimal` and `Real + BigDecimal`. + +See [OperatorInterop.md](OperatorInterop.md) for the generic mechanism behind that. + +## String Representation + +Use `toStringExpanded()` when you want plain decimal output without scientific notation: + +```lyng +import lyng.decimal + +assertEquals("12.34", "12.34".d.toStringExpanded()) +``` + +This is the recommended representation for: + +- tests +- user-visible diagnostics +- decimal formatting checks + +## Conversions Back To Built-ins + +```lyng +import lyng.decimal + +assertEquals(2, "2.9".d.toInt()) +assertEquals(2.9, "2.9".d.toReal()) +``` + +Use `toReal()` only when you are willing to return to binary floating-point semantics. + +## Division Context + +Division is the operation where precision and rounding matter most. + +By default, decimal division uses: + +- precision: `34` significant digits +- rounding: `HalfEven` + +Example: + +```lyng +import lyng.decimal + +assertEquals("0.3333333333333333333333333333333333", (1.d / 3.d).toStringExpanded()) +assertEquals("0.6666666666666666666666666666666667", ("2".d / 3.d).toStringExpanded()) +``` + +## `withDecimalContext(...)` + +Use `withDecimalContext(...)` to override decimal division rules inside a block: + +```lyng +import lyng.decimal + +assertEquals( + "0.3333333333", + withDecimalContext(10) { (1.d / 3.d).toStringExpanded() } +) +``` + +You can also pass an explicit context object: + +```lyng +import lyng.decimal + +val ctx = DecimalContext(6, DecimalRounding.HalfAwayFromZero) +assertEquals("0.666667", withDecimalContext(ctx) { ("2".d / 3.d).toStringExpanded() }) +``` + +The context is dynamic and local to the block. After the block exits, the previous context is restored. + +## Rounding Modes + +Available rounding modes: + +- `HalfEven` +- `HalfAwayFromZero` +- `HalfTowardsZero` +- `Ceiling` +- `Floor` +- `AwayFromZero` +- `TowardsZero` + +Tie example at precision `2`: + +```lyng +import lyng.decimal + +assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfEven) { (1.d / 8.d).toStringExpanded() }) +assertEquals("0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (1.d / 8.d).toStringExpanded() }) +assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (1.d / 8.d).toStringExpanded() }) +assertEquals("0.13", withDecimalContext(2, DecimalRounding.Ceiling) { (1.d / 8.d).toStringExpanded() }) +assertEquals("0.12", withDecimalContext(2, DecimalRounding.Floor) { (1.d / 8.d).toStringExpanded() }) +``` + +Negative values follow the same named policy in the obvious direction: + +```lyng +import lyng.decimal + +assertEquals("-0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (-1.d / 8.d).toStringExpanded() }) +assertEquals("-0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (-1.d / 8.d).toStringExpanded() }) +``` + +## Recommended Usage Rules + +If you care about exact decimal source text: + +```lyng +"12.34".d +``` + +If you intentionally convert an existing binary floating-point value: + +```lyng +someReal.d +``` + +If you want local control over division: + +```lyng +withDecimalContext(precision, rounding) { ... } +``` + +If you want custom mixed operators for your own type, follow the same pattern as Decimal and use the operator registry: + +- define the operators on your own class +- choose a common class +- register mixed operand bridges + +See [OperatorInterop.md](OperatorInterop.md). diff --git a/docs/OperatorInterop.md b/docs/OperatorInterop.md new file mode 100644 index 0000000..cf244d0 --- /dev/null +++ b/docs/OperatorInterop.md @@ -0,0 +1,309 @@ +# Operator Interop Registry + +`lyng.operators` provides a runtime registry for mixed-class binary operators. + +Import it when you want expressions such as: + +- `1 + MyType(...)` +- `2 < MyType(...)` +- `3 == MyType(...)` + +to work without modifying the built-in `Int` or `Real` classes. + +```lyng +import lyng.operators +``` + +## Why This Exists + +If your class defines: + +```lyng +class Amount(val value: Int) { + fun plus(other: Amount) = Amount(value + other.value) +} +``` + +then: + +```lyng +Amount(1) + Amount(2) +``` + +works, because the left operand already knows how to add another `Amount`. + +But: + +```lyng +1 + Amount(2) +``` + +does not naturally work, because `Int` has not been rewritten to know about `Amount`. + +The operator interop registry solves exactly that problem. + +## Mental Model + +Registration describes a mixed pair: + +- left class `L` +- right class `R` +- common class `C` + +When Lyng sees `L op R`, it: + +1. converts `L -> C` +2. converts `R -> C` +3. evaluates the operator as `C op C` + +So the registry is a bridge, not a separate arithmetic engine. + +## API + +```lyng +OperatorInterop.register( + leftClass, + rightClass, + commonClass, + operators, + leftToCommon, + rightToCommon +) +``` + +Parameters: + +- `leftClass`: original left operand class +- `rightClass`: original right operand class +- `commonClass`: class that will actually execute the operator methods +- `operators`: list of operators enabled for this pair +- `leftToCommon`: conversion from left operand to common class +- `rightToCommon`: conversion from right operand to common class + +## Supported Operators + +`BinaryOperator` values: + +- `Plus` +- `Minus` +- `Mul` +- `Div` +- `Mod` +- `Compare` +- `Equals` + +Meaning: + +- `Compare` enables `<`, `<=`, `>`, `>=`, and `<=>` +- `Equals` enables `==` and `!=` + +## Minimal Working Example + +```lyng +package test.decimalbox +import lyng.operators + +class DecimalBox(val value: Int) { + fun plus(other: DecimalBox) = DecimalBox(value + other.value) + fun minus(other: DecimalBox) = DecimalBox(value - other.value) + fun mul(other: DecimalBox) = DecimalBox(value * other.value) + fun div(other: DecimalBox) = DecimalBox(value / other.value) + fun mod(other: DecimalBox) = DecimalBox(value % other.value) + fun compareTo(other: DecimalBox) = value <=> other.value +} + +OperatorInterop.register( + Int, + DecimalBox, + DecimalBox, + [ + BinaryOperator.Plus, + BinaryOperator.Minus, + BinaryOperator.Mul, + BinaryOperator.Div, + BinaryOperator.Mod, + BinaryOperator.Compare, + BinaryOperator.Equals + ], + { x: Int -> DecimalBox(x) }, + { x: DecimalBox -> x } +) +``` + +Then: + +```lyng +import test.decimalbox + +assertEquals(DecimalBox(3), 1 + DecimalBox(2)) +assertEquals(DecimalBox(1), 3 - DecimalBox(2)) +assertEquals(DecimalBox(8), 4 * DecimalBox(2)) +assertEquals(DecimalBox(4), 8 / DecimalBox(2)) +assertEquals(DecimalBox(1), 7 % DecimalBox(2)) +assert(1 < DecimalBox(2)) +assert(2 <= DecimalBox(2)) +assert(3 > DecimalBox(2)) +assert(2 == DecimalBox(2)) +assert(2 != DecimalBox(3)) +``` + +## How Decimal Uses It + +`lyng.decimal` uses this same mechanism so that: + +```lyng +import lyng.decimal + +1 + 2.d +0.5 + 1.d +2 == 2.d +3 > 2.d +``` + +work naturally even though `Int` and `Real` themselves were not edited to know `BigDecimal`. + +The shape is: + +- `leftClass = Int` or `Real` +- `rightClass = BigDecimal` +- `commonClass = BigDecimal` +- convert built-ins into `BigDecimal` +- leave `BigDecimal` values unchanged + +## Step-By-Step Pattern For Your Own Type + +### 1. Pick the common class + +Choose one class that will be the actual arithmetic domain. + +For numeric-like types, that is usually your own class: + +```lyng +class Rational(...) +``` + +### 2. Implement operators on that class + +The common class should define the operations you plan to register. + +Example: + +```lyng +class Rational(val num: Int, val den: Int) { + fun plus(other: Rational) = Rational(num * other.den + other.num * den, den * other.den) + fun minus(other: Rational) = Rational(num * other.den - other.num * den, den * other.den) + fun mul(other: Rational) = Rational(num * other.num, den * other.den) + fun div(other: Rational) = Rational(num * other.den, den * other.num) + fun compareTo(other: Rational) = (num * other.den) <=> (other.num * den) + + static fun fromInt(value: Int) = Rational(value, 1) +} +``` + +### 3. Register the mixed pair + +```lyng +import lyng.operators + +OperatorInterop.register( + Int, + Rational, + Rational, + [ + BinaryOperator.Plus, + BinaryOperator.Minus, + BinaryOperator.Mul, + BinaryOperator.Div, + BinaryOperator.Compare, + BinaryOperator.Equals + ], + { x: Int -> Rational.fromInt(x) }, + { x: Rational -> x } +) +``` + +### 4. Use it + +```lyng +assertEquals(Rational(3, 2), 1 + Rational(1, 2)) +assert(Rational(3, 2) == Rational(3, 2)) +assert(2 > Rational(3, 2)) +``` + +## Registering More Than One Built-in Type + +If you want both `Int + MyType` and `Real + MyType`, register both pairs explicitly: + +```lyng +OperatorInterop.register( + Int, + MyType, + MyType, + [BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals], + { x: Int -> MyType.fromInt(x) }, + { x: MyType -> x } +) + +OperatorInterop.register( + Real, + MyType, + MyType, + [BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals], + { x: Real -> MyType.fromReal(x) }, + { x: MyType -> x } +) +``` + +Each mixed pair is independent. + +## Pure Lyng Registration + +This mechanism is intentionally useful from pure Lyng code, not only from Kotlin-backed modules. + +That means you can: + +- declare a class in Lyng +- define its operators in Lyng +- register mixed operand bridges in Lyng + +without touching compiler internals. + +## Where To Register + +Register once during module initialization. + +Top-level module code is a good place: + +```lyng +package my.rational +import lyng.operators + +class Rational(...) + +OperatorInterop.register(...) +``` + +That keeps registration close to the type declaration and makes importing the module enough to activate the interop. + +## What Registration Does Not Do + +The registry does not: + +- invent operators your common class does not implement +- change the original `Int`, `Real`, or other built-ins +- automatically cover every class pair +- replace normal method overload resolution when the left-hand class already knows what to do + +It only teaches Lyng how to bridge a specific mixed pair into a common class for the listed operators. + +## Recommended Design Rules + +If you want interop to feel natural: + +- choose one obvious common class +- make conversions explicit and unsurprising +- implement `compareTo` if you want ordering operators +- register `Equals` whenever mixed equality should work +- keep the registered operator list minimal and accurate + +For decimal-like semantics, also read [Decimal.md](Decimal.md). diff --git a/docs/whats_new.md b/docs/whats_new.md index 0936e48..4f02a94 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -5,6 +5,73 @@ For a programmer-focused migration summary, see `docs/whats_new_1_5.md`. ## Language Features +### Decimal Arithmetic Module (`lyng.decimal`) +Lyng now ships a first-class decimal module built as a regular extension library rather than a deep core special case. + +It provides: + +- `BigDecimal` +- convenient `.d` conversions from `Int`, `Real`, and `String` +- mixed arithmetic with `Int` and `Real` +- local division precision and rounding control via `withDecimalContext(...)` + +```lyng +import lyng.decimal + +assertEquals("3", (1 + 2.d).toStringExpanded()) +assertEquals("0.30000000000000004", (0.1 + 0.2).d.toStringExpanded()) +assertEquals("0.3", "0.3".d.toStringExpanded()) + +assertEquals( + "0.3333333333", + withDecimalContext(10) { (1.d / 3.d).toStringExpanded() } +) +``` + +The distinction between `Real -> Decimal` and exact decimal parsing is explicit by design: + +- `2.2.d` converts the current `Real` value +- `"2.2".d` parses exact decimal text + +See [Decimal](Decimal.md). + +### Binary Operator Interop Registry +Lyng now provides a general mechanism for mixed binary operators through `lyng.operators`. + +This solves cases like: + +- `Int + MyType` +- `Real < MyType` +- `Int == MyType` + +without requiring changes to built-in classes. + +```lyng +import lyng.operators + +class DecimalBox(val value: Int) { + fun plus(other: DecimalBox) = DecimalBox(value + other.value) + fun compareTo(other: DecimalBox) = value <=> other.value +} + +OperatorInterop.register( + Int, + DecimalBox, + DecimalBox, + [BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals], + { x: Int -> DecimalBox(x) }, + { x: DecimalBox -> x } +) + +assertEquals(DecimalBox(3), 1 + DecimalBox(2)) +assert(1 < DecimalBox(2)) +assert(2 == DecimalBox(2)) +``` + +`lyng.decimal` uses this same mechanism internally to interoperate with `Int` and `Real`. + +See [Operator Interop Registry](OperatorInterop.md). + ### Class Properties with Accessors Classes now support properties with custom `get()` and `set()` accessors. Properties in Lyng do **not** have automatic backing fields; they are pure accessors. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9bc18d2..aaa2c20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ android-compileSdk = "34" kotlinx-coroutines = "1.10.2" kotlinx-datetime = "0.6.1" mp_bintools = "0.3.2" +ionspin-bignum = "0.3.10" firebaseCrashlyticsBuildtools = "3.0.3" okioVersion = "3.10.2" compiler = "3.2.0-alpha11" @@ -22,6 +23,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } mp_bintools = { module = "net.sergeych:mp_bintools", version.ref = "mp_bintools" } +ionspin-bignum = { module = "com.ionspin.kotlin:bignum", version.ref = "ionspin-bignum" } firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" } okio = { module = "com.squareup.okio:okio", version.ref = "okioVersion" } okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okioVersion" } diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index 4927be8..a9ed38b 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -101,6 +101,7 @@ kotlin { //put your multiplatform dependencies here api(libs.kotlinx.coroutines.core) api(libs.mp.bintools) + implementation(libs.ionspin.bignum) } } val commonTest by getting { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 9b52b49..df072aa 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -4667,6 +4667,7 @@ class Compiler( } is MethodCallRef -> methodReturnTypeDeclByRef[ref] is CallRef -> callReturnTypeDeclByRef[ref] + is BinaryOpRef -> inferBinaryOpReturnTypeDecl(ref) is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverTypeDecl(it.ref) } else -> null } @@ -4730,6 +4731,7 @@ class Compiler( is ThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName()) is QualifiedThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName()) is CallRef -> inferCallReturnClass(ref) + is BinaryOpRef -> inferBinaryOpReturnClass(ref) is FieldRef -> { val targetClass = resolveReceiverClassForMember(ref.target) inferFieldReturnClass(targetClass, ref.name) @@ -4744,10 +4746,105 @@ class Compiler( } } + private fun typeDeclOfClass(objClass: ObjClass): TypeDecl = TypeDecl.Simple(objClass.className, false) + + private fun binaryOpMethodName(op: BinOp): String? = when (op) { + BinOp.PLUS -> "plus" + BinOp.MINUS -> "minus" + BinOp.STAR -> "mul" + BinOp.SLASH -> "div" + BinOp.PERCENT -> "mod" + else -> null + } + + private fun interopOperatorFor(op: BinOp): InteropOperator? = when (op) { + BinOp.PLUS -> InteropOperator.Plus + BinOp.MINUS -> InteropOperator.Minus + BinOp.STAR -> InteropOperator.Mul + BinOp.SLASH -> InteropOperator.Div + BinOp.PERCENT -> InteropOperator.Mod + BinOp.LT, BinOp.LTE, BinOp.GT, BinOp.GTE -> InteropOperator.Compare + BinOp.EQ, BinOp.NEQ -> InteropOperator.Equals + else -> null + } + + private fun sameClassArithmeticFallback(leftClass: ObjClass, rightClass: ObjClass, op: BinOp): TypeDecl? { + if (leftClass !== rightClass) return null + val methodName = binaryOpMethodName(op) ?: return null + if (leftClass.getInstanceMemberOrNull(methodName, includeAbstract = true) == null) return null + return typeDeclOfClass(leftClass) + } + + private fun inferBinaryOpReturnTypeDecl(ref: BinaryOpRef): TypeDecl? { + val leftClass = resolveReceiverClassForMember(ref.left) ?: inferObjClassFromRef(ref.left) + val rightClass = resolveReceiverClassForMember(ref.right) ?: inferObjClassFromRef(ref.right) + val boolType = typeDeclOfClass(ObjBool.type) + val intType = typeDeclOfClass(ObjInt.type) + val realType = typeDeclOfClass(ObjReal.type) + val stringType = typeDeclOfClass(ObjString.type) + + when (ref.op) { + BinOp.OR, BinOp.AND, + BinOp.EQARROW, BinOp.EQ, BinOp.NEQ, BinOp.REF_EQ, BinOp.REF_NEQ, BinOp.MATCH, BinOp.NOTMATCH, + BinOp.LTE, BinOp.LT, BinOp.GTE, BinOp.GT, + BinOp.IN, BinOp.NOTIN, + BinOp.IS, BinOp.NOTIS -> return boolType + else -> {} + } + + if (leftClass == null || rightClass == null) return null + + val leftIsInt = leftClass == ObjInt.type + val rightIsInt = rightClass == ObjInt.type + val leftIsReal = leftClass == ObjReal.type + val rightIsReal = rightClass == ObjReal.type + val leftIsNumeric = leftIsInt || leftIsReal + val rightIsNumeric = rightIsInt || rightIsReal + + return when (ref.op) { + BinOp.PLUS -> when { + leftIsInt && rightIsInt -> intType + leftIsNumeric && rightIsNumeric -> realType + leftClass == ObjString.type -> stringType + interopOperatorFor(ref.op)?.let { + OperatorInteropRegistry.commonClassFor(leftClass, rightClass, it) + } != null -> typeDeclOfClass( + OperatorInteropRegistry.commonClassFor(leftClass, rightClass, interopOperatorFor(ref.op)!!)!! + ) + else -> binaryOpMethodName(ref.op)?.let { classMethodReturnTypeDecl(leftClass, it) } + ?: sameClassArithmeticFallback(leftClass, rightClass, ref.op) + } + BinOp.MINUS, BinOp.STAR, BinOp.SLASH, BinOp.PERCENT -> when { + leftIsInt && rightIsInt -> intType + leftIsNumeric && rightIsNumeric -> realType + ref.op == BinOp.STAR && leftClass == ObjString.type && rightIsNumeric -> stringType + interopOperatorFor(ref.op)?.let { + OperatorInteropRegistry.commonClassFor(leftClass, rightClass, it) + } != null -> typeDeclOfClass( + OperatorInteropRegistry.commonClassFor(leftClass, rightClass, interopOperatorFor(ref.op)!!)!! + ) + else -> binaryOpMethodName(ref.op)?.let { classMethodReturnTypeDecl(leftClass, it) } + ?: sameClassArithmeticFallback(leftClass, rightClass, ref.op) + } + BinOp.BAND, BinOp.BOR, BinOp.BXOR, BinOp.SHL, BinOp.SHR -> + if (leftIsInt && rightIsInt) intType else null + else -> null + } + } + private fun inferBinaryOpReturnClass(ref: BinaryOpRef): ObjClass? { + inferBinaryOpReturnTypeDecl(ref)?.let { declared -> + resolveTypeDeclObjClass(declared)?.let { return it } + } val leftClass = resolveReceiverClassForMember(ref.left) ?: inferObjClassFromRef(ref.left) val rightClass = resolveReceiverClassForMember(ref.right) ?: inferObjClassFromRef(ref.right) if (leftClass == null || rightClass == null) return null + interopOperatorFor(ref.op)?.let { op -> + OperatorInteropRegistry.commonClassFor(leftClass, rightClass, op)?.let { return it } + } + sameClassArithmeticFallback(leftClass, rightClass, ref.op)?.let { declared -> + resolveTypeDeclObjClass(declared)?.let { return it } + } return when (ref.op) { BinOp.PLUS, BinOp.MINUS -> when { leftClass == ObjInstant.type && rightClass == ObjInstant.type && ref.op == BinOp.MINUS -> ObjDuration.type diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/OperatorInteropRegistry.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/OperatorInteropRegistry.kt new file mode 100644 index 0000000..877c0c5 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/OperatorInteropRegistry.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng + +import net.sergeych.lyng.obj.Obj +import net.sergeych.lyng.obj.ObjClass +import net.sergeych.lyng.obj.ObjInt +import net.sergeych.lyng.obj.ObjVoid + +internal enum class InteropOperator(val memberName: String?) { + Plus("plus"), + Minus("minus"), + Mul("mul"), + Div("div"), + Mod("mod"), + Compare("compareTo"), + Equals("equals"); + + companion object { + fun fromName(name: String): InteropOperator = + entries.firstOrNull { it.name == name } + ?: throw IllegalArgumentException("unknown interop operator: $name") + } +} + +private data class InteropRule( + val commonClass: ObjClass, + val operators: Set, + val leftToCommon: Obj, + val rightToCommon: Obj +) + +internal data class PromotedOperands(val left: Obj, val right: Obj) + +internal object OperatorInteropRegistry { + private val rules = mutableMapOf, InteropRule>() + + fun register( + leftClass: ObjClass, + rightClass: ObjClass, + commonClass: ObjClass, + operatorNames: List, + leftToCommon: Obj, + rightToCommon: Obj + ) { + val operators = operatorNames.mapTo(linkedSetOf(), InteropOperator::fromName) + rules[leftClass to rightClass] = InteropRule(commonClass, operators, leftToCommon, rightToCommon) + if (leftClass !== rightClass) { + rules[rightClass to leftClass] = InteropRule(commonClass, operators, rightToCommon, leftToCommon) + } + } + + suspend fun promote(scope: Scope, left: Obj, right: Obj, operator: InteropOperator): PromotedOperands? { + val leftValue = unwrap(left) + val rightValue = unwrap(right) + val rule = rules[leftValue.objClass to rightValue.objClass] ?: return null + if (operator !in rule.operators) return null + val promotedLeft = rule.leftToCommon.invoke(scope, ObjVoid, Arguments(leftValue)) + val promotedRight = rule.rightToCommon.invoke(scope, ObjVoid, Arguments(rightValue)) + if (promotedLeft.objClass !== rule.commonClass || promotedRight.objClass !== rule.commonClass) { + scope.raiseIllegalState( + "Operator interop promotion must return ${rule.commonClass.className}, " + + "got ${promotedLeft.objClass.className} and ${promotedRight.objClass.className}" + ) + } + return PromotedOperands(promotedLeft, promotedRight) + } + + suspend fun invokeBinary(scope: Scope, left: Obj, right: Obj, operator: InteropOperator): Obj? { + val promoted = promote(scope, left, right, operator) ?: return null + val memberName = operator.memberName ?: return null + return promoted.left.invokeInstanceMethod(scope, memberName, Arguments(promoted.right)) + } + + suspend fun invokeCompare(scope: Scope, left: Obj, right: Obj): Int? { + val promoted = promote(scope, left, right, InteropOperator.Compare) ?: return null + return promoted.left.invokeInstanceMethod(scope, "compareTo", Arguments(promoted.right)) + .cast(scope) + .value + .toInt() + } + + fun commonClassFor(leftClass: ObjClass, rightClass: ObjClass, operator: InteropOperator): ObjClass? { + val rule = rules[leftClass to rightClass] ?: return null + if (operator !in rule.operators) return null + return rule.commonClass + } + + private suspend fun unwrap(obj: Obj): Obj = when (obj) { + is FrameSlotRef -> obj.read() + is RecordSlotRef -> obj.read() + else -> obj + } +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index 2a414db..2d5b973 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -27,7 +27,9 @@ import net.sergeych.lyng.bytecode.CmdVm import net.sergeych.lyng.miniast.* import net.sergeych.lyng.obj.* import net.sergeych.lyng.pacman.ImportManager +import net.sergeych.lyng.stdlib_included.decimalLyng import net.sergeych.lyng.stdlib_included.observableLyng +import net.sergeych.lyng.stdlib_included.operatorsLyng import net.sergeych.lyng.stdlib_included.rootLyng import net.sergeych.lynon.ObjLynonClass import net.sergeych.mp_tools.globalDefer @@ -727,6 +729,36 @@ class Script( module.addConst("ChangeRejectionException", ObjChangeRejectionExceptionClass) module.eval(Source("lyng.observable", observableLyng)) } + addPackage("lyng.operators") { module -> + module.eval(Source("lyng.operators", operatorsLyng)) + module.bindObject("OperatorInterop") { + addFun("register") { + val leftClass = requiredArg(0) + val rightClass = requiredArg(1) + val commonClass = requiredArg(2) + val operators = requiredArg(3).list.map { value -> + val entry = value as? ObjEnumEntry + ?: requireScope().raiseIllegalArgument( + "OperatorInterop.register expects BinaryOperator enum entries" + ) + entry.name.value + } + OperatorInteropRegistry.register( + leftClass = leftClass, + rightClass = rightClass, + commonClass = commonClass, + operatorNames = operators, + leftToCommon = args[4], + rightToCommon = args[5] + ) + ObjVoid + } + } + } + addPackage("lyng.decimal") { module -> + module.eval(Source("lyng.decimal", decimalLyng)) + ObjBigDecimalSupport.bindTo(module) + } addPackage("lyng.buffer") { it.addConstDoc( name = "Buffer", diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt index 2c4c4e5..c0f9b50 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt @@ -25,6 +25,8 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.serializer import net.sergeych.lyng.* +import net.sergeych.lyng.InteropOperator +import net.sergeych.lyng.OperatorInteropRegistry import net.sergeych.lyng.miniast.ParamDoc import net.sergeych.lyng.miniast.addFnDoc import net.sergeych.lyng.miniast.type @@ -175,7 +177,8 @@ open class Obj { if (other === this) return 0 if (other === ObjNull || other === ObjUnset || other === ObjVoid) return 2 return invokeInstanceMethod(scope, "compareTo", Arguments(other)) { - scope.raiseNotImplemented("compareTo for ${objClass.className}") + OperatorInteropRegistry.invokeCompare(scope, this, other)?.let { ObjInt.of(it.toLong()) } + ?: scope.raiseNotImplemented("compareTo for ${objClass.className}") }.cast(scope).toInt() } @@ -284,7 +287,8 @@ open class Obj { } if (self !== this) return self.plus(scope, otherValue) return invokeInstanceMethod(scope, "plus", Arguments(otherValue)) { - scope.raiseNotImplemented("plus for ${objClass.className}") + OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Plus) + ?: scope.raiseNotImplemented("plus for ${objClass.className}") } } @@ -301,7 +305,8 @@ open class Obj { } if (self !== this) return self.minus(scope, otherValue) return invokeInstanceMethod(scope, "minus", Arguments(otherValue)) { - scope.raiseNotImplemented("minus for ${objClass.className}") + OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Minus) + ?: scope.raiseNotImplemented("minus for ${objClass.className}") } } @@ -324,7 +329,8 @@ open class Obj { } if (self !== this) return self.mul(scope, otherValue) return invokeInstanceMethod(scope, "mul", Arguments(otherValue)) { - scope.raiseNotImplemented("mul for ${objClass.className}") + OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Mul) + ?: scope.raiseNotImplemented("mul for ${objClass.className}") } } @@ -341,7 +347,8 @@ open class Obj { } if (self !== this) return self.div(scope, otherValue) return invokeInstanceMethod(scope, "div", Arguments(otherValue)) { - scope.raiseNotImplemented("div for ${objClass.className}") + OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Div) + ?: scope.raiseNotImplemented("div for ${objClass.className}") } } @@ -358,7 +365,8 @@ open class Obj { } if (self !== this) return self.mod(scope, otherValue) return invokeInstanceMethod(scope, "mod", Arguments(otherValue)) { - scope.raiseNotImplemented("mod for ${objClass.className}") + OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Mod) + ?: scope.raiseNotImplemented("mod for ${objClass.className}") } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjBigDecimalSupport.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjBigDecimalSupport.kt new file mode 100644 index 0000000..5fa93df --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjBigDecimalSupport.kt @@ -0,0 +1,313 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.obj + +import com.ionspin.kotlin.bignum.decimal.BigDecimal as IonBigDecimal +import com.ionspin.kotlin.bignum.decimal.DecimalMode +import com.ionspin.kotlin.bignum.decimal.RoundingMode +import com.ionspin.kotlin.bignum.integer.BigInteger +import net.sergeych.lyng.Arguments +import net.sergeych.lyng.FrameSlotRef +import net.sergeych.lyng.InteropOperator +import net.sergeych.lyng.ModuleScope +import net.sergeych.lyng.OperatorInteropRegistry +import net.sergeych.lyng.RecordSlotRef +import net.sergeych.lyng.Scope +import net.sergeych.lyng.ScopeFacade +import net.sergeych.lyng.TypeDecl +import net.sergeych.lyng.miniast.addPropertyDoc +import net.sergeych.lyng.miniast.type +import net.sergeych.lyng.requiredArg + +object ObjBigDecimalSupport { + private const val decimalContextVar = "__lyng_decimal_context__" + // For Real -> BigDecimal, preserve the actual IEEE-754 Double value using a + // round-trip-safe precision. This intentionally does not try to recover source text. + private val realConversionMode = DecimalMode(17L, RoundingMode.ROUND_HALF_TO_EVEN) + // Division needs an explicit stopping rule for non-terminating results. Use a + // decimal128-like default context until Lyng exposes per-operation contexts. + private val defaultDivisionMode = DecimalMode(34L, RoundingMode.ROUND_HALF_TO_EVEN) + private val zero: IonBigDecimal = IonBigDecimal.ZERO + private val decimalTypeDecl = TypeDecl.Simple("lyng.decimal.BigDecimal", false) + private object BoundMarker + private data class DecimalRuntimeContext( + val precision: Long, + val rounding: RoundingMode + ) : Obj() + + suspend fun bindTo(module: ModuleScope) { + val decimalClass = module.requireClass("BigDecimal") + if (decimalClass.kotlinClassData === BoundMarker) return + decimalClass.kotlinClassData = BoundMarker + decimalClass.isAbstract = false + val hooks = decimalClass.bridgeInitHooks ?: mutableListOf Unit>().also { + decimalClass.bridgeInitHooks = it + } + hooks += { _, instance -> + instance.kotlinInstanceData = zero + } + decimalClass.addFn("plus") { + newInstance(decimalClass, valueOf(thisObj).plus(coerceArg(requireScope(), args.firstAndOnly()))) + } + decimalClass.addFn("minus") { + newInstance(decimalClass, valueOf(thisObj).minus(coerceArg(requireScope(), args.firstAndOnly()))) + } + decimalClass.addFn("mul") { + newInstance(decimalClass, valueOf(thisObj).times(coerceArg(requireScope(), args.firstAndOnly()))) + } + decimalClass.addFn("div") { + newInstance(decimalClass, divideWithContext(valueOf(thisObj), coerceArg(requireScope(), args.firstAndOnly()), currentDivisionMode(requireScope()))) + } + decimalClass.addFn("mod") { + newInstance(decimalClass, valueOf(thisObj).rem(coerceArg(requireScope(), args.firstAndOnly()))) + } + decimalClass.addFn("compareTo") { + ObjInt.of(valueOf(thisObj).compareTo(coerceArg(requireScope(), args.firstAndOnly())).toLong()) + } + decimalClass.addFn("negate") { + newInstance(decimalClass, valueOf(thisObj).unaryMinus()) + } + decimalClass.addFn("toInt") { + ObjInt.of(valueOf(thisObj).longValue(false)) + } + decimalClass.addFn("toReal") { + ObjReal.of(valueOf(thisObj).doubleValue(false)) + } + decimalClass.addFn("toStringExpanded") { + ObjString(valueOf(thisObj).toStringExpanded()) + } + decimalClass.addClassFn("fromInt") { + val value = requiredArg(0).value + newInstance(decimalClass, IonBigDecimal.fromLongAsSignificand(value)) + } + decimalClass.addClassFn("fromReal") { + val value = requiredArg(0).value + newInstance(decimalClass, IonBigDecimal.fromDouble(value, realConversionMode)) + } + decimalClass.addClassFn("fromString") { + val value = requiredArg(0).value + try { + newInstance(decimalClass, IonBigDecimal.parseStringWithMode(value)) + } catch (e: Throwable) { + requireScope().raiseIllegalArgument("invalid BigDecimal string: $value") + } + } + module.addFn("withDecimalContext") { + val (context, block) = when (args.list.size) { + 2 -> { + val first = args[0] + val block = args[1] + if (first is ObjInt) { + DecimalRuntimeContext(first.value, RoundingMode.ROUND_HALF_TO_EVEN) to block + } else { + normalizeContext(requireScope(), first) to block + } + } + 3 -> { + val precision = requiredArg(0).value + val rounding = roundingModeFromObj(requireScope(), args[1]) + DecimalRuntimeContext(precision, rounding) to args[2] + } + else -> requireScope().raiseIllegalArgument("withDecimalContext expects (context, block), (precision, block), or (precision, rounding, block)") + } + val child = requireScope().createChildScope() + child.addConst(decimalContextVar, context) + block.callOn(child) + } + registerBuiltinConversions(decimalClass) + registerInterop(decimalClass) + } + + private fun valueOf(obj: Obj): IonBigDecimal { + val instance = obj as? ObjInstance ?: error("BigDecimal receiver must be an object instance") + return instance.kotlinInstanceData as? IonBigDecimal ?: zero + } + + private suspend fun currentDivisionMode(scope: Scope): DecimalMode { + val context = findContextObject(scope) ?: return defaultDivisionMode + return DecimalMode(context.precision, context.rounding) + } + + private fun divideWithContext(left: IonBigDecimal, right: IonBigDecimal, mode: DecimalMode): IonBigDecimal { + if (mode.decimalPrecision <= 0L) { + return stripMode(left.divide(right, mode)) + } + val exactLeft = stripMode(left) + val exactRight = stripMode(right) + val guardMode = DecimalMode(mode.decimalPrecision + 2, RoundingMode.TOWARDS_ZERO) + var guarded = stripMode(exactLeft.divide(exactRight, guardMode)) + val hasMoreTail = !stripMode(exactLeft - stripMode(guarded * exactRight)).isZero() + if (hasMoreTail && isHalfRounding(mode.roundingMode) && looksLikeExactHalf(guarded, mode.decimalPrecision)) { + guarded = nudgeLastDigitAwayFromZero(guarded) + } + return stripMode(guarded.roundSignificand(mode)) + } + + private suspend fun ScopeFacade.newInstance(decimalClass: ObjClass, value: IonBigDecimal): ObjInstance { + val instance = call(decimalClass) as? ObjInstance + ?: raiseIllegalState("BigDecimal() did not return an object instance") + instance.kotlinInstanceData = value + return instance + } + + private fun coerceArg(scope: Scope, value: Obj): IonBigDecimal = when (value) { + is ObjInt -> IonBigDecimal.fromLongAsSignificand(value.value) + is ObjReal -> IonBigDecimal.fromDouble(value.value, realConversionMode) + is ObjInstance -> { + if (value.objClass.className != "BigDecimal") { + scope.raiseIllegalArgument("expected BigDecimal-compatible value, got ${value.objClass.className}") + } + value.kotlinInstanceData as? IonBigDecimal ?: zero + } + else -> scope.raiseIllegalArgument("expected BigDecimal-compatible value, got ${value.objClass.className}") + } + + private suspend fun normalizeContext(scope: Scope, value: Obj): DecimalRuntimeContext { + val instance = value as? ObjInstance + ?: scope.raiseClassCastError("withDecimalContext expects DecimalContext as the first argument") + if (instance.objClass.className != "DecimalContext") { + scope.raiseClassCastError("withDecimalContext expects DecimalContext as the first argument") + } + return decimalRuntimeContextFromInstance(scope, instance) + } + + private fun findContextObject(scope: Scope): DecimalRuntimeContext? { + var current: Scope? = scope + while (current != null) { + val record = current.objects[decimalContextVar] ?: current.localBindings[decimalContextVar] + val value = when (val raw = record?.value) { + is FrameSlotRef -> raw.peekValue() + is RecordSlotRef -> raw.peekValue() + else -> raw + } + when (value) { + is DecimalRuntimeContext -> return value + } + current = current.parent + } + return null + } + + private suspend fun decimalRuntimeContextFromInstance(scope: Scope, context: ObjInstance): DecimalRuntimeContext { + val precision = context.readField(scope, "precision").value as? ObjInt + ?: scope.raiseClassCastError("DecimalContext.precision must be Int") + if (precision.value <= 0L) { + scope.raiseIllegalArgument("DecimalContext precision must be positive") + } + val rounding = roundingModeFromObj(scope, context.readField(scope, "rounding").value) + return DecimalRuntimeContext(precision.value, rounding) + } + + private fun stripMode(value: IonBigDecimal): IonBigDecimal = + IonBigDecimal.fromBigIntegerWithExponent(value.significand, value.exponent) + + private fun isHalfRounding(mode: RoundingMode): Boolean = when (mode) { + RoundingMode.ROUND_HALF_TO_EVEN, + RoundingMode.ROUND_HALF_AWAY_FROM_ZERO, + RoundingMode.ROUND_HALF_TOWARDS_ZERO, + RoundingMode.ROUND_HALF_CEILING, + RoundingMode.ROUND_HALF_FLOOR, + RoundingMode.ROUND_HALF_TO_ODD -> true + else -> false + } + + private fun looksLikeExactHalf(value: IonBigDecimal, targetPrecision: Long): Boolean { + val digits = value.significand.abs().toString(10) + if (digits.length <= targetPrecision) return false + val discarded = digits.substring(targetPrecision.toInt()) + return discarded[0] == '5' && discarded.drop(1).all { it == '0' } + } + + private fun nudgeLastDigitAwayFromZero(value: IonBigDecimal): IonBigDecimal { + val ulpExponent = value.exponent - value.precision + 1 + val ulp = IonBigDecimal.fromBigIntegerWithExponent(BigInteger.ONE, ulpExponent) + return if (value.significand.signum() < 0) value - ulp else value + ulp + } + + private fun roundingModeFromObj(scope: Scope, value: Obj): RoundingMode { + val entry = value as? ObjEnumEntry ?: scope.raiseClassCastError("DecimalContext.rounding must be DecimalRounding") + return when (entry.name.value) { + "HalfEven" -> RoundingMode.ROUND_HALF_TO_EVEN + "HalfAwayFromZero" -> RoundingMode.ROUND_HALF_AWAY_FROM_ZERO + "HalfTowardsZero" -> RoundingMode.ROUND_HALF_TOWARDS_ZERO + "Ceiling" -> RoundingMode.CEILING + "Floor" -> RoundingMode.FLOOR + "AwayFromZero" -> RoundingMode.AWAY_FROM_ZERO + "TowardsZero" -> RoundingMode.TOWARDS_ZERO + else -> scope.raiseIllegalArgument("unsupported DecimalRounding: ${entry.name.value}") + } + } + + private fun registerBuiltinConversions(decimalClass: ObjClass) { + ObjInt.type.addPropertyDoc( + name = "d", + doc = "Convert this integer to a BigDecimal.", + type = type("lyng.decimal.BigDecimal"), + moduleName = "lyng.decimal", + getter = { newInstance(decimalClass, IonBigDecimal.fromLongAsSignificand(thisAs().value)) } + ) + ObjInt.type.members["d"] = ObjInt.type.members.getValue("d").copy(typeDecl = decimalTypeDecl) + ObjReal.type.addPropertyDoc( + name = "d", + doc = "Convert this real number to a BigDecimal by preserving the current IEEE-754 value with 17 significant digits and half-even rounding.", + type = type("lyng.decimal.BigDecimal"), + moduleName = "lyng.decimal", + getter = { newInstance(decimalClass, IonBigDecimal.fromDouble(thisAs().value, realConversionMode)) } + ) + ObjReal.type.members["d"] = ObjReal.type.members.getValue("d").copy(typeDecl = decimalTypeDecl) + ObjString.type.addPropertyDoc( + name = "d", + doc = "Parse this string as a BigDecimal.", + type = type("lyng.decimal.BigDecimal"), + moduleName = "lyng.decimal", + getter = { + val value = thisAs().value + try { + newInstance(decimalClass, IonBigDecimal.parseStringWithMode(value)) + } catch (e: Throwable) { + requireScope().raiseIllegalArgument("invalid BigDecimal string: $value") + } + } + ) + ObjString.type.members["d"] = ObjString.type.members.getValue("d").copy(typeDecl = decimalTypeDecl) + } + + private fun registerInterop(decimalClass: ObjClass) { + OperatorInteropRegistry.register( + leftClass = ObjInt.type, + rightClass = decimalClass, + commonClass = decimalClass, + operatorNames = listOf( + InteropOperator.Plus.name, + InteropOperator.Minus.name, + InteropOperator.Mul.name, + InteropOperator.Div.name, + InteropOperator.Mod.name, + InteropOperator.Compare.name, + InteropOperator.Equals.name + ), + leftToCommon = ObjExternCallable.fromBridge { + val value = requiredArg(0).value + newInstance(decimalClass, IonBigDecimal.fromLongAsSignificand(value)) + }, + rightToCommon = ObjExternCallable.fromBridge { + requiredArg(0) + } + ) + } +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt index ec5ab43..e7a68d8 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt @@ -364,6 +364,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { onNotFoundResult: (suspend () -> Obj?)? ): Obj { val caller = scope.currentClassCtx + val methodScope = scope.applyClosure(instanceScope) // Fast path for public members when outside any class context if (caller == null) { @@ -373,7 +374,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { if (rec.type == ObjRecord.Type.Property) { if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl) } else if (rec.type == ObjRecord.Type.Fun) { - return rec.value.invoke(instanceScope, this, args, decl) + return rec.value.invoke(methodScope, this, args, decl) } } } @@ -384,7 +385,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { if (rec.type == ObjRecord.Type.Property) { if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl) } else if (rec.type == ObjRecord.Type.Fun) { - return rec.value.invoke(instanceScope, this, args, decl) + return rec.value.invoke(methodScope, this, args, decl) } } } @@ -394,7 +395,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { if (rec.type == ObjRecord.Type.Property) { if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl) } else if (rec.type == ObjRecord.Type.Fun) { - return rec.value.invoke(instanceScope, this, args, decl) + return rec.value.invoke(methodScope, this, args, decl) } } } @@ -409,7 +410,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { if (rec.type == ObjRecord.Type.Property) { if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, c) } else if (rec.type == ObjRecord.Type.Fun) { - return rec.value.invoke(instanceScope, this, args, c) + return rec.value.invoke(methodScope, this, args, c) } } } @@ -418,7 +419,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { if (rec.type == ObjRecord.Type.Property) { if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, c) } else if (rec.type == ObjRecord.Type.Fun) { - return rec.value.invoke(instanceScope, this, args, c) + return rec.value.invoke(methodScope, this, args, c) } } } @@ -427,7 +428,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { if (rec.type == ObjRecord.Type.Property) { if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, c) } else if (rec.type == ObjRecord.Type.Fun) { - return rec.value.invoke(instanceScope, this, args, c) + return rec.value.invoke(methodScope, this, args, c) } } } @@ -439,7 +440,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) ?: objClass val effectiveCaller = caller ?: if (scope.thisObj === this) objClass else null if (canAccessMember(rec.visibility, decl, effectiveCaller, name)) { - return rec.value.invoke(instanceScope, this, args, decl) + return rec.value.invoke(methodScope, this, args, decl) } } } @@ -474,7 +475,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl) } else if (rec.type == ObjRecord.Type.Fun) { return rec.value.invoke( - instanceScope, + methodScope, this, args, decl @@ -586,8 +587,10 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { } override suspend fun compareTo(scope: Scope, other: Obj): Int { - if (other !is ObjInstance) return -1 - if (other.objClass != objClass) return -1 + if (other !is ObjInstance || other.objClass != objClass) { + OperatorInteropRegistry.invokeCompare(scope, this, other)?.let { return it } + return -1 + } for (f in comparableVars) { val a = f.value.value val b = other.instanceScope.objects[f.key]?.value ?: scope.raiseError("Internal error: field ${f.key} not found in other instance") diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInt.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInt.kt index 708f5ec..84f71b0 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInt.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInt.kt @@ -19,6 +19,8 @@ package net.sergeych.lyng.obj import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive +import net.sergeych.lyng.InteropOperator +import net.sergeych.lyng.OperatorInteropRegistry import net.sergeych.lyng.Scope import net.sergeych.lyng.miniast.addFnDoc import net.sergeych.lynon.LynonDecoder @@ -54,10 +56,11 @@ class ObjInt(val value: Long, override val isConst: Boolean = false) : Obj(), Nu } override suspend fun compareTo(scope: Scope, other: Obj): Int { - if (other !is Numeric) return -2 return if (other is ObjInt) { value.compareTo(other.value) } else { + OperatorInteropRegistry.invokeCompare(scope, this, other)?.let { return it } + if (other !is Numeric) return -2 doubleValue.compareTo(other.doubleValue) } } @@ -70,28 +73,39 @@ class ObjInt(val value: Long, override val isConst: Boolean = false) : Obj(), Nu if (other is ObjInt) of(this.value + other.value) else + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Plus) + ?: ObjReal.of(this.doubleValue + other.toDouble()) override suspend fun minus(scope: Scope, other: Obj): Obj = if (other is ObjInt) of(this.value - other.value) else + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Minus) + ?: ObjReal.of(this.doubleValue - other.toDouble()) override suspend fun mul(scope: Scope, other: Obj): Obj = if (other is ObjInt) { of(this.value * other.value) - } else ObjReal.of(this.value * other.toDouble()) + } else { + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Mul) + ?: ObjReal.of(this.value * other.toDouble()) + } override suspend fun div(scope: Scope, other: Obj): Obj = if (other is ObjInt) of(this.value / other.value) - else ObjReal.of(this.value / other.toDouble()) + else + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Div) + ?: ObjReal.of(this.value / other.toDouble()) override suspend fun mod(scope: Scope, other: Obj): Obj = if (other is ObjInt) of(this.value % other.value) - else ObjReal.of(this.value.toDouble() % other.toDouble()) + else + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Mod) + ?: ObjReal.of(this.value.toDouble() % other.toDouble()) /** * Numbers are now immutable, so we can't do in-place assignment. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjReal.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjReal.kt index 13685b2..c3465ec 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjReal.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjReal.kt @@ -19,6 +19,8 @@ package net.sergeych.lyng.obj import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive +import net.sergeych.lyng.InteropOperator +import net.sergeych.lyng.OperatorInteropRegistry import net.sergeych.lyng.Scope import net.sergeych.lyng.miniast.addConstDoc import net.sergeych.lyng.miniast.addFnDoc @@ -40,6 +42,8 @@ data class ObjReal(val value: Double) : Obj(), Numeric { override fun byValueCopy(): Obj = this override suspend fun compareTo(scope: Scope, other: Obj): Int { + if (other is ObjReal) return value.compareTo(other.value) + OperatorInteropRegistry.invokeCompare(scope, this, other)?.let { return it } if (other !is Numeric) return -2 return value.compareTo(other.doubleValue) } @@ -63,19 +67,24 @@ data class ObjReal(val value: Double) : Obj(), Numeric { } override suspend fun plus(scope: Scope, other: Obj): Obj = - of(this.value + other.toDouble()) + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Plus) + ?: of(this.value + other.toDouble()) override suspend fun minus(scope: Scope, other: Obj): Obj = - of(this.value - other.toDouble()) + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Minus) + ?: of(this.value - other.toDouble()) override suspend fun mul(scope: Scope, other: Obj): Obj = - of(this.value * other.toDouble()) + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Mul) + ?: of(this.value * other.toDouble()) override suspend fun div(scope: Scope, other: Obj): Obj = - of(this.value / other.toDouble()) + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Div) + ?: of(this.value / other.toDouble()) override suspend fun mod(scope: Scope, other: Obj): Obj = - of(this.value % other.toDouble()) + OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Mod) + ?: of(this.value % other.toDouble()) /** * Returns unboxed Double value diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/decimal_lyng.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/decimal_lyng.kt new file mode 100644 index 0000000..5c3e457 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/decimal_lyng.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.stdlib_included + +@Suppress("Unused", "MemberVisibilityCanBePrivate") +internal val decimalLyng = """ +package lyng.decimal + +/** + * Rounding policies used by [DecimalContext] and `withDecimalContext(...)`. + * + * These modes currently affect decimal division. They are designed to be explicit and readable in Lyng code. + * + * Common examples at precision `2`: + * - `HalfEven`: `1.d / 8.d -> 0.12` + * - `HalfAwayFromZero`: `1.d / 8.d -> 0.13`, `-1.d / 8.d -> -0.13` + * - `HalfTowardsZero`: `1.d / 8.d -> 0.12`, `-1.d / 8.d -> -0.12` + * - `Ceiling`: rounds toward positive infinity + * - `Floor`: rounds toward negative infinity + * - `AwayFromZero`: always increases magnitude when rounding is needed + * - `TowardsZero`: always truncates toward zero + */ +enum DecimalRounding { + HalfEven, + HalfAwayFromZero, + HalfTowardsZero, + Ceiling, + Floor, + AwayFromZero, + TowardsZero +} + +/** + * Dynamic decimal arithmetic settings. + * + * A decimal context is not attached permanently to a `BigDecimal` value. Instead, it is applied dynamically + * inside `withDecimalContext(...)`, which makes the rule local to a block of code. + * + * Default context: + * - precision: `34` significant digits + * - rounding: `DecimalRounding.HalfEven` + * + * Example: + * + * import lyng.decimal + * + * (1.d / 3.d).toStringExpanded() + * >>> "0.3333333333333333333333333333333333" + * + * withDecimalContext(10) { (1.d / 3.d).toStringExpanded() } + * >>> "0.3333333333" + * + * withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (1.d / 8.d).toStringExpanded() } + * >>> "0.13" + */ +class DecimalContext( + val precision: Int = 34, + val rounding: DecimalRounding = DecimalRounding.HalfEven +) + +/** + * Arbitrary-precision decimal value. + * + * `BigDecimal` is intended for decimal arithmetic where binary floating-point (`Real`) is the wrong tool: + * - money + * - human-entered decimal values + * - ratios that should round in decimal, not in binary + * - reproducible decimal formatting + * + * Creating values: + * + * - `1.d` converts `Int -> BigDecimal` + * - `2.2.d` converts `Real -> BigDecimal` by preserving the current IEEE-754 value + * - `"2.2".d` parses exact decimal text + * - `BigDecimal.fromInt(...)`, `fromReal(...)`, `fromString(...)` are explicit factory forms + * + * Important distinction: + * + * - `2.2.d` means "take the current `Real` value and convert it" + * - `"2.2".d` means "parse this exact decimal literal text" + * + * Therefore: + * + * import lyng.decimal + * + * 2.2.d.toStringExpanded() + * >>> "2.2" + * + * (0.1 + 0.2).d.toStringExpanded() + * >>> "0.30000000000000004" + * + * "0.3".d.toStringExpanded() + * >>> "0.3" + * + * Mixed arithmetic: + * + * `BigDecimal` defines its own operators against decimal-compatible values, and the decimal module also registers + * interop bridges so built-in left-hand operands work naturally: + * + * import lyng.decimal + * + * 1.d + 2 + * >>> 3.d + * + * 1 + 2.d + * >>> 3.d + * + * 0.5 + 1.d + * >>> 1.5.d + * + * Precision and rounding: + * + * - division uses the default decimal context unless overridden + * - use `withDecimalContext(...)` to apply a local precision/rounding policy + * + * Exact decimal literal style: + * + * If you want the source text itself to be the decimal value, use a string: + * + * "2.2".d + * + * That is the precise form. `2.2.d` remains a `Real -> BigDecimal` conversion by design. + */ +extern class BigDecimal() { + /** Add another decimal-compatible value. */ + extern fun plus(other: Object): BigDecimal + /** Subtract another decimal-compatible value. */ + extern fun minus(other: Object): BigDecimal + /** Multiply by another decimal-compatible value. */ + extern fun mul(other: Object): BigDecimal + /** + * Divide by another decimal-compatible value. + * + * Division uses the current decimal context: + * - by default: `34` significant digits, `HalfEven` + * - inside `withDecimalContext(...)`: the context active for the current block + */ + extern fun div(other: Object): BigDecimal + /** Remainder with another decimal-compatible value. */ + extern fun mod(other: Object): BigDecimal + /** Compare with another decimal-compatible value. */ + extern fun compareTo(other: Object): Int + /** Unary minus. */ + extern fun negate(): BigDecimal + /** Convert to `Int` by dropping the fractional part according to backend conversion rules. */ + extern fun toInt(): Int + /** Convert to `Real`. */ + extern fun toReal(): Real + /** + * Convert to a plain decimal string without scientific notation. + * + * This is the preferred representation for user-facing decimal tests and diagnostics. + */ + extern fun toStringExpanded(): String + + /** Create a decimal from an `Int`. */ + static extern fun fromInt(value: Int): BigDecimal + /** + * Create a decimal from a `Real`. + * + * This preserves the current IEEE-754 value using a round-trip-safe decimal conversion. + * It does not try to recover the original source text. + */ + static extern fun fromReal(value: Real): BigDecimal + /** Parse exact decimal text. */ + static extern fun fromString(value: String): BigDecimal +} + +/** + * Run [block] with the provided decimal context. + * + * This is the main way to control decimal division precision and rounding locally without changing global behavior. + * + * Example: + * + * import lyng.decimal + * + * withDecimalContext(10) { + * (1.d / 3.d).toStringExpanded() + * } + * >>> "0.3333333333" + * + * Contexts are dynamic and block-local. After the block finishes, the previous context is restored. + */ +extern fun withDecimalContext(context: DecimalContext, block: ()->T): T + +/** + * Convenience overload for changing only precision. + * + * Equivalent to `withDecimalContext(DecimalContext(precision, DecimalRounding.HalfEven), block)`. + */ +extern fun withDecimalContext(precision: Int, block: ()->T): T + +/** + * Convenience overload for changing precision and rounding explicitly. + * + * Example: + * + * import lyng.decimal + * + * withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { + * (1.d / 8.d).toStringExpanded() + * } + * >>> "0.13" + */ +extern fun withDecimalContext(precision: Int, rounding: DecimalRounding, block: ()->T): T +""".trimIndent() diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/operators_lyng.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/operators_lyng.kt new file mode 100644 index 0000000..08f5ede --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/operators_lyng.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.stdlib_included + +@Suppress("Unused", "MemberVisibilityCanBePrivate") +internal val operatorsLyng = """ +package lyng.operators + +/** + * Binary operators that can be bridged between two different operand classes. + * + * Registering a pair means: + * - the runtime can evaluate `left op right` when `left` has class `L` and `right` has class `R` + * - both operands are first converted to a shared "common" class `C` + * - the actual operator implementation is then looked up on `C` + * + * This is primarily useful when: + * - you add a new numeric-like type in a library or in pure Lyng code + * - your type already implements operators against itself + * - you also want existing left-hand types such as `Int` or `Real` to work when your type is on the right + * + * Example: + * + * import lyng.operators + * + * class DecimalBox(val value: Int) { + * fun plus(other: DecimalBox) = DecimalBox(value + other.value) + * fun compareTo(other: DecimalBox) = value <=> other.value + * } + * + * OperatorInterop.register( + * Int, + * DecimalBox, + * DecimalBox, + * [BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals], + * { x: Int -> DecimalBox(x) }, + * { x: DecimalBox -> x } + * ) + * + * After registration: + * - `1 + DecimalBox(2)` works + * - `1 < DecimalBox(2)` works + * - `2 == DecimalBox(2)` works + * + * But this registration does not replace methods on the original classes. It only teaches + * Lyng how to bridge a mixed pair into a common class for the listed operators. + */ +enum BinaryOperator { + /** `a + b` */ + Plus, + /** `a - b` */ + Minus, + /** `a * b` */ + Mul, + /** `a / b` */ + Div, + /** `a % b` */ + Mod, + /** + * Ordering comparisons. + * + * Registering `Compare` enables `<`, `<=`, `>`, `>=`, and the shuttle operator `<=>` + * for the mixed operand pair. + */ + Compare, + /** + * Equality comparisons. + * + * Registering `Equals` enables `==` and `!=` for the mixed operand pair. + */ + Equals +} + +/** + * Runtime registry for mixed-class binary operators. + * + * `register(L, R, C, ...)` defines how Lyng should evaluate expressions where: + * - the left operand has class `L` + * - the right operand has class `R` + * - the actual operation should be executed as if both were values of class `C` + * + * The registry is symmetric for the converted values, but not for the original syntax. + * Its job is specifically to fill the gap where your custom type appears on the right: + * + * - `myDecimal + 1` usually already works if `BigDecimal.plus(Int)` exists + * - `1 + myDecimal` needs registration because `Int` itself is not rewritten + * + * Typical pattern for a custom type: + * + * import lyng.operators + * + * class Rational(val num: Int, val den: Int) { + * fun plus(other: Rational) = Rational(num * other.den + other.num * den, den * other.den) + * fun minus(other: Rational) = Rational(num * other.den - other.num * den, den * other.den) + * fun mul(other: Rational) = Rational(num * other.num, den * other.den) + * fun div(other: Rational) = Rational(num * other.den, den * other.num) + * fun compareTo(other: Rational) = (num * other.den) <=> (other.num * den) + * + * static fun fromInt(value: Int) = Rational(value, 1) + * } + * + * OperatorInterop.register( + * Int, + * Rational, + * Rational, + * [ + * BinaryOperator.Plus, + * BinaryOperator.Minus, + * BinaryOperator.Mul, + * BinaryOperator.Div, + * BinaryOperator.Compare, + * BinaryOperator.Equals + * ], + * { x: Int -> Rational.fromInt(x) }, + * { x: Rational -> x } + * ) + * + * Then: + * - `1 + Rational(1, 2)` works + * - `3 > Rational(5, 2)` works + * - `2 == Rational(2, 1)` works + * + * Decimal uses the same mechanism internally to make `Int + BigDecimal` and `Real + BigDecimal` + * work without changing the built-in `Int` or `Real` classes. + */ +extern object OperatorInterop { + /** + * Register a mixed-operand operator bridge. + * + * @param leftClass class of the original left operand + * @param rightClass class of the original right operand + * @param commonClass class that will actually execute the operator methods + * @param operators operators supported for this mixed pair + * @param leftToCommon conversion from `L` to `C` + * @param rightToCommon conversion from `R` to `C` + * + * Requirements for `commonClass`: + * - if you register `Plus`, `C` should implement `fun plus(other: C): C` or equivalent accepted result type + * - if you register `Minus`, `C` should implement `fun minus(other: C): ...` + * - if you register `Mul`, `C` should implement `fun mul(other: C): ...` + * - if you register `Div`, `C` should implement `fun div(other: C): ...` + * - if you register `Mod`, `C` should implement `fun mod(other: C): ...` + * - if you register `Compare`, `C` should implement `fun compareTo(other: C): Int` + * + * `Equals` reuses comparison/equality semantics of the promoted values. + * + * Registration is usually done once at module initialization time: + * + * package my.rational + * import lyng.operators + * + * class Rational(val num: Int, val den: Int) { + * fun plus(other: Rational) = Rational(num * other.den + other.num * den, den * other.den) + * fun compareTo(other: Rational) = (num * other.den) <=> (other.num * den) + * static fun fromInt(value: Int) = Rational(value, 1) + * } + * + * OperatorInterop.register( + * Int, + * Rational, + * Rational, + * [BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals], + * { x: Int -> Rational.fromInt(x) }, + * { x: Rational -> x } + * ) + */ + extern fun register( + leftClass: Class, + rightClass: Class, + commonClass: Class, + operators: List, + leftToCommon: (L)->C, + rightToCommon: (R)->C + ): Void +} +""".trimIndent() diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/BigDecimalModuleTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/BigDecimalModuleTest.kt new file mode 100644 index 0000000..5539fa9 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/BigDecimalModuleTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class BigDecimalModuleTest { + @Test + fun testDecimalModuleFactoriesAndConversions() = runTest { + val scope = Script.newScope() + scope.eval( + """ + import lyng.decimal + + assertEquals("12.34", BigDecimal.fromString("12.34").toStringExpanded()) + assertEquals("1", BigDecimal.fromInt(1).toStringExpanded()) + assertEquals("2.5", "2.5".d.toStringExpanded()) + assertEquals("1", 1.d.toStringExpanded()) + assertEquals("2.2", 2.2.d.toStringExpanded()) + assertEquals("3", (1 + 2).d.toStringExpanded()) + assertEquals("1.5", (1 + 0.5).d.toStringExpanded()) + assertEquals("3", (1 + 2.d).toStringExpanded()) + assertEquals("3", (2.d + 1).toStringExpanded()) + assertEquals(2.5, "2.5".d.toReal()) + assertEquals(2, "2.5".d.toInt()) + assertEquals(2.2, 2.2.d.toReal()) + assertEquals("0.30000000000000004", (0.1 + 0.2).d.toStringExpanded()) + """.trimIndent() + ) + } + + @Test + fun testDecimalModuleMixedIntOperators() = runTest { + val scope = Script.newScope() + scope.eval( + """ + import lyng.decimal + + assertEquals(3.d, 1 + 2.d) + assertEquals(3.d, 2.d + 1) + assertEquals(1.d, 3 - 2.d) + assertEquals(1.d, 3.d - 2) + assertEquals(8.d, 4 * 2.d) + assertEquals(8.d, 4.d * 2) + assertEquals(4.d, 8 / 2.d) + assertEquals(4.d, 8.d / 2) + assertEquals(1.d, 7 % 2.d) + assertEquals(1.d, 7.d % 2) + assert(1 < 2.d) + assert(2 <= 2.d) + assert(3 > 2.d) + assert(3.d > 2) + assert(2 == 2.d) + assert(2.d == 2) + """.trimIndent() + ) + } + + @Test + fun testDecimalDivisionUsesDefaultContext() = runTest { + val scope = Script.newScope() + scope.eval( + """ + import lyng.decimal + + assertEquals("0.125", (1.d / 8.d).toStringExpanded()) + assertEquals("0.3333333333333333333333333333333333", (1.d / 3.d).toStringExpanded()) + assertEquals("0.6666666666666666666666666666666667", ("2".d / 3.d).toStringExpanded()) + """.trimIndent() + ) + } + + @Test + fun testWithDecimalContextOverridesDivisionContext() = runTest { + val scope = Script.newScope() + scope.eval( + """ + import lyng.decimal + + assertEquals("0.3333333333333333333333333333333333", (1.d / 3.d).toStringExpanded()) + assertEquals("0.3333333333", withDecimalContext(10) { (1.d / 3.d).toStringExpanded() }) + assertEquals("0.666667", withDecimalContext(6) { ("2".d / 3.d).toStringExpanded() }) + assertEquals("0.666667", withDecimalContext(DecimalContext(6)) { ("2".d / 3.d).toStringExpanded() }) + assertEquals("0.12", withDecimalContext(2) { (1.d / 8.d).toStringExpanded() }) + assertEquals("0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (1.d / 8.d).toStringExpanded() }) + assertEquals("0.13", withDecimalContext(DecimalContext(2, DecimalRounding.HalfAwayFromZero)) { (1.d / 8.d).toStringExpanded() }) + assertEquals("0.3333333333333333333333333333333333", (1.d / 3.d).toStringExpanded()) + """.trimIndent() + ) + } + + @Test + fun testDecimalDivisionRoundingMatrix() = runTest { + val scope = Script.newScope() + scope.eval( + """ + import lyng.decimal + + assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfEven) { (1.d / 8.d).toStringExpanded() }) + assertEquals("-0.12", withDecimalContext(2, DecimalRounding.HalfEven) { (-1.d / 8.d).toStringExpanded() }) + + assertEquals("0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (1.d / 8.d).toStringExpanded() }) + assertEquals("-0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (-1.d / 8.d).toStringExpanded() }) + + assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (1.d / 8.d).toStringExpanded() }) + assertEquals("-0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (-1.d / 8.d).toStringExpanded() }) + + assertEquals("0.13", withDecimalContext(2, DecimalRounding.Ceiling) { (1.d / 8.d).toStringExpanded() }) + assertEquals("-0.12", withDecimalContext(2, DecimalRounding.Ceiling) { (-1.d / 8.d).toStringExpanded() }) + + assertEquals("0.12", withDecimalContext(2, DecimalRounding.Floor) { (1.d / 8.d).toStringExpanded() }) + assertEquals("-0.13", withDecimalContext(2, DecimalRounding.Floor) { (-1.d / 8.d).toStringExpanded() }) + + assertEquals("0.13", withDecimalContext(2, DecimalRounding.AwayFromZero) { (1.d / 8.d).toStringExpanded() }) + assertEquals("-0.13", withDecimalContext(2, DecimalRounding.AwayFromZero) { (-1.d / 8.d).toStringExpanded() }) + + assertEquals("0.12", withDecimalContext(2, DecimalRounding.TowardsZero) { (1.d / 8.d).toStringExpanded() }) + assertEquals("-0.12", withDecimalContext(2, DecimalRounding.TowardsZero) { (-1.d / 8.d).toStringExpanded() }) + """.trimIndent() + ) + } +} diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/OperatorInteropTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/OperatorInteropTest.kt new file mode 100644 index 0000000..f360fbb --- /dev/null +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/OperatorInteropTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class OperatorInteropTest { + @Test + fun testPureLyngOperatorInteropRegistration() = runTest { + val im = Script.defaultImportManager.copy() + im.addPackage("test.decimalbox") { scope -> + scope.eval( + """ + package test.decimalbox + import lyng.operators + + class DecimalBox(val value: Int) { + fun plus(other: DecimalBox) = DecimalBox(value + other.value) + fun minus(other: DecimalBox) = DecimalBox(value - other.value) + fun mul(other: DecimalBox) = DecimalBox(value * other.value) + fun div(other: DecimalBox) = DecimalBox(value / other.value) + fun mod(other: DecimalBox) = DecimalBox(value % other.value) + fun compareTo(other: DecimalBox) = value <=> other.value + } + + OperatorInterop.register( + Int, + DecimalBox, + DecimalBox, + [BinaryOperator.Plus, BinaryOperator.Minus, BinaryOperator.Mul, BinaryOperator.Div, BinaryOperator.Mod, BinaryOperator.Compare, BinaryOperator.Equals], + { x: Int -> DecimalBox(x) }, + { x: DecimalBox -> x } + ) + """.trimIndent() + ) + } + + val scope = im.newStdScope() + scope.eval( + """ + import test.decimalbox + + assertEquals(DecimalBox(3), 1 + DecimalBox(2)) + assertEquals(DecimalBox(1), 3 - DecimalBox(2)) + assertEquals(DecimalBox(8), 4 * DecimalBox(2)) + assertEquals(DecimalBox(4), 8 / DecimalBox(2)) + assertEquals(DecimalBox(1), 7 % DecimalBox(2)) + assert(1 < DecimalBox(2)) + assert(2 <= DecimalBox(2)) + assert(3 > DecimalBox(2)) + assert(2 >= DecimalBox(2)) + assert(2 == DecimalBox(2)) + assert(2 != DecimalBox(3)) + """.trimIndent() + ) + } + + @Test + fun testRealInteropRegistrationUsesTopLevelModuleCode() = runTest { + val im = Script.defaultImportManager.copy() + im.addPackage("test.realbox") { scope -> + scope.eval( + """ + package test.realbox + import lyng.operators + + class RealBox(val value: Real) { + fun plus(other: RealBox) = RealBox(value + other.value) + fun compareTo(other: RealBox) = value <=> other.value + } + + OperatorInterop.register( + Real, + RealBox, + RealBox, + [BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals], + { x: Real -> RealBox(x) }, + { x: RealBox -> x } + ) + """.trimIndent() + ) + } + + val scope = im.newStdScope() + scope.eval( + """ + import test.realbox + + assertEquals(RealBox(1.75), 0.5 + RealBox(1.25)) + assert(1.5 < RealBox(2.0)) + assert(2.0 == RealBox(2.0)) + """.trimIndent() + ) + } +} diff --git a/site/src/jsMain/kotlin/HomePage.kt b/site/src/jsMain/kotlin/HomePage.kt index c03f4ea..f379202 100644 --- a/site/src/jsMain/kotlin/HomePage.kt +++ b/site/src/jsMain/kotlin/HomePage.kt @@ -27,74 +27,85 @@ import org.jetbrains.compose.web.dom.* fun HomePage() { val samples = remember { listOf( + """ + // Decimal arithmetic with explicit local precision + import lyng.decimal + + val exact = "0.3".d + val fromReal = (0.1 + 0.2).d + + println(exact.toStringExpanded()) + println(fromReal.toStringExpanded()) + println(withDecimalContext(10) { (1.d / 3.d).toStringExpanded() }) + """.trimIndent(), + """ + // Mixed operators for your own type + import lyng.operators + + class Coins(val amount: Int) { + fun plus(other: Coins) = Coins(amount + other.amount) + fun compareTo(other: Coins) = amount <=> other.amount + } + + OperatorInterop.register( + Int, + Coins, + Coins, + [BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals], + { x: Int -> Coins(x) }, + { x: Coins -> x } + ) + + println(1 + Coins(2)) + println(3 > Coins(2)) + """.trimIndent(), + """ + // Non-local returns from closures + fun findFirst(list: Iterable, predicate: (T)->Bool): T? { + list.forEach { + if (predicate(it)) return@findFirst it + } + null + } + + val found: Int? = findFirst([1, 5, 8, 12]) { it > 10 } + println("Found: " + found) + """.trimIndent(), + """ + // Implicit coroutines: parallelism without ceremony + import lyng.time + + val d1 = launch { + delay(100.milliseconds) + "Task A finished" + } + val d2 = launch { + delay(50.milliseconds) + "Task B finished" + } + + println(d1.await()) + println(d2.await()) + """.trimIndent(), """ // Everything is an expression val x: Int = 10 val status: String = if (x > 0) "Positive" else "Zero or Negative" - // Even loops return values! val result = for (i in 1..5) { if (i == 3) break "Found 3!" } else "Not found" - println("Result: " + result) + println(status) + println(result) """.trimIndent(), """ - // Functional power with generics and collections + // Functional collections with strict static types val squares: List = (1..10) .filter { it % 2 == 0 } .map { it * it } println("Even squares: " + squares) - // Output: [4, 16, 36, 64, 100] - """.trimIndent(), - """ - // Generics and type aliases - type Num = Int | Real - - class Box(val value: T) { - fun get(): T = value - } - - val intBox = Box(42) - val realBox = Box(3.14) - println("Boxes: " + intBox.get() + ", " + realBox.get()) - """.trimIndent(), - """ - // Strict compile-time types and symbol resolution - fun greet(name: String, count: Int) { - for (i in 1..count) { - println("Hello, " + name + "!") - } - } - - greet("Lyng", 3) - // greet(10, "error") // This would be a compile-time error! - """.trimIndent(), - """ - // Flexible map literals and shorthands - val id = 101 - val name = "Lyng" - val base = { id:, name: } // Shorthand for id: id, name: name - - val full = { ...base, version: "1.5.0-SNAPSHOT", status: "active" } - println(full) - """.trimIndent(), - """ - // Modern null safety - var config: Map? = null - config ?= { timeout: 30 } // Assign only if null - - val timeout = config?["timeout"] ?: 60 - println("Timeout is: " + timeout) - """.trimIndent(), - """ - // Destructuring with splat operator - val [first, middle..., last] = [1, 2, 3, 4, 5, 6] - - println("First: " + first) - println("Middle: " + middle) - println("Last: " + last) """.trimIndent(), """ // Diamond-safe Multiple Inheritance (C3 MRO) @@ -123,18 +134,6 @@ fun HomePage() { println([10, 20, 30].second) """.trimIndent(), """ - // Non-local returns from closures - fun findFirst(list: Iterable, predicate: (T)->Bool): T? { - list.forEach { - if (predicate(it)) return@findFirst it - } - null - } - - val found: Int? = findFirst([1, 5, 8, 12]) { it > 10 } - println("Found: " + found) - """.trimIndent(), - """ // Easy operator overloading class Vector(val x: Real, val y: Real) { fun plus(other: Vector): Vector = Vector(x + other.x, y + other.y) @@ -156,20 +155,13 @@ fun HomePage() { println("User name: " + u.name) """.trimIndent(), """ - // Implicit coroutines: parallelism without ceremony - import lyng.time + // Flexible map literals and shorthands + val id = 101 + val name = "Lyng" + val base = { id:, name: } + val full = { ...base, status: "active", tags: ["typed", "portable"] } - val d1 = launch { - delay(100.milliseconds) - "Task A finished" - } - val d2 = launch { - delay(50.milliseconds) - "Task B finished" - } - - println(d1.await()) - println(d2.await()) + println(full) """.trimIndent() ) } @@ -195,6 +187,8 @@ fun HomePage() { Div({ classes("d-flex", "justify-content-center", "gap-2", "flex-wrap", "mb-4") }) { // Benefits pills listOf( + "Decimal arithmetic", + "Operator interop", "Strict static typing", "Generics & Type Aliases", "Implicit coroutines", @@ -233,6 +227,26 @@ fun HomePage() { } } + Div({ classes("row", "g-3", "mb-4") }) { + listOf( + Triple("New: Decimal", "Exact decimal values, local precision control, and mixed operators with Int and Real.", "#/docs/Decimal.md"), + Triple("New: Operator Interop", "Teach Lyng how your custom types interact with built-ins on the left-hand side.", "#/docs/OperatorInterop.md"), + Triple("What Changed", "Recent language, stdlib, and tooling improvements in one place.", "#/docs/whats_new.md") + ).forEach { (title, text, href) -> + Div({ classes("col-12", "col-lg-4") }) { + A(attrs = { + classes("text-decoration-none") + attr("href", href) + }) { + Div({ classes("h-100", "p-3", "border", "rounded-3", "bg-body-tertiary") }) { + H3({ classes("h5", "mb-2", "text-body") }) { Text(title) } + P({ classes("mb-0", "text-muted") }) { Text(text) } + } + } + } + } + } + // Code sample slideshow Div({ classes("markdown-body", "mt-0", "slide-container", "position-relative")