Add decimal module and operator interop registry

This commit is contained in:
Sergey Chernov 2026-03-27 21:31:37 +03:00
parent 4269310beb
commit 580256d520
18 changed files with 1992 additions and 102 deletions

250
docs/Decimal.md Normal file
View File

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

309
docs/OperatorInterop.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<InteropOperator>,
val leftToCommon: Obj,
val rightToCommon: Obj
)
internal data class PromotedOperands(val left: Obj, val right: Obj)
internal object OperatorInteropRegistry {
private val rules = mutableMapOf<Pair<ObjClass, ObjClass>, InteropRule>()
fun register(
leftClass: ObjClass,
rightClass: ObjClass,
commonClass: ObjClass,
operatorNames: List<String>,
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<ObjInt>(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
}
}

View File

@ -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<ObjClass>(0)
val rightClass = requiredArg<ObjClass>(1)
val commonClass = requiredArg<ObjClass>(2)
val operators = requiredArg<ObjList>(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",

View File

@ -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<ObjInt>(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}")
}
}

View File

@ -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<suspend (ScopeFacade, ObjInstance) -> 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<ObjInt>(0).value
newInstance(decimalClass, IonBigDecimal.fromLongAsSignificand(value))
}
decimalClass.addClassFn("fromReal") {
val value = requiredArg<ObjReal>(0).value
newInstance(decimalClass, IonBigDecimal.fromDouble(value, realConversionMode))
}
decimalClass.addClassFn("fromString") {
val value = requiredArg<ObjString>(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<ObjInt>(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<ObjInt>().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<ObjReal>().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<ObjString>().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<ObjInt>(0).value
newInstance(decimalClass, IonBigDecimal.fromLongAsSignificand(value))
},
rightToCommon = ObjExternCallable.fromBridge {
requiredArg<Obj>(0)
}
)
}
}

View File

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

View File

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

View File

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

View File

@ -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<T>(context: DecimalContext, block: ()->T): T
/**
* Convenience overload for changing only precision.
*
* Equivalent to `withDecimalContext(DecimalContext(precision, DecimalRounding.HalfEven), block)`.
*/
extern fun withDecimalContext<T>(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<T>(precision: Int, rounding: DecimalRounding, block: ()->T): T
""".trimIndent()

View File

@ -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<L, R, C>(
leftClass: Class<L>,
rightClass: Class<R>,
commonClass: Class<C>,
operators: List<BinaryOperator>,
leftToCommon: (L)->C,
rightToCommon: (R)->C
): Void
}
""".trimIndent()

View File

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

View File

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

View File

@ -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<T>(list: Iterable<T>, 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<Int> = (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<out T: Num>(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<String, Int>? = 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<T>(list: Iterable<T>, 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")