Add decimal module and operator interop registry

This commit is contained in:
sergeych committed 2026-03-27 21:31:37 +03:00
1 parent 4269310beb
commit 580256d520
18 files changed
+1992 -102

No files matched your search

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