Compare commits

..
7 Commits
36 changed files with 2743 additions and 235 deletions

No files matched your search

+250
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
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).
+22
View File
@@ -72,6 +72,14 @@ For module-level APIs, the default workflow is:
1. declare globals in Lyng using `extern fun` / `extern val` / `extern var`;
2. bind Kotlin implementation via `ModuleScope.globalBinder()`.
This is also the recommended way to expose a Kotlin-backed value that should behave like a true
Lyng global variable/property. If you need `x` to read/write through Kotlin on every access, use
`extern var` / `extern val` plus `bindGlobalVar(...)`.
Do not use `addConst(...)` for this case: `addConst(...)` installs a value, not a Kotlin-backed
property accessor. It is appropriate for fixed values and objects, but not for a global that should
delegate reads/writes back into Kotlin state.
```kotlin
import net.sergeych.lyng.bridge.*
import net.sergeych.lyng.obj.ObjInt
@@ -117,6 +125,12 @@ assertEquals("changed", globalProp)
assertEquals("1.0.0", globalVersion)
```
Minimal rule of thumb:
- use `bindGlobalFun(...)` for global functions
- use `bindGlobalVar(...)` for Kotlin-backed global variables/properties
- use `addConst(...)` only for fixed values/objects that do not need getter/setter behavior
For custom argument handling and full runtime access:
```kotlin
@@ -462,14 +476,21 @@ im.addPackage("my.tools") { module: ModuleScope ->
module.eval(
"""
extern val version: String
extern var status: String
extern fun triple(x: Int): Int
""".trimIndent()
)
val binder = module.globalBinder()
var status = "ready"
binder.bindGlobalVar(
name = "version",
get = { "1.0" }
)
binder.bindGlobalVar(
name = "status",
get = { status },
set = { status = it }
)
binder.bindGlobalFun1<Int>("triple") { x ->
ObjInt.of((x * 3).toLong())
}
@@ -479,6 +500,7 @@ im.addPackage("my.tools") { module: ModuleScope ->
scope.eval("""
import my.tools.*
val v = triple(14)
status = "busy"
""")
val v = scope.eval("v").toKotlin(scope) // -> 42
```
+67
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.
+2
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" }
+1
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 {
@@ -184,6 +184,7 @@ class Compiler(
private val encodedPayloadTypeByName: MutableMap<String, ObjClass> = mutableMapOf()
private val objectDeclNames: MutableSet<String> = mutableSetOf()
private val externCallableNames: MutableSet<String> = mutableSetOf()
private val externBindingNames: MutableSet<String> = mutableSetOf()
private val moduleDeclaredNames: MutableSet<String> = mutableSetOf()
private var seedingSlotPlan: Boolean = false
@@ -192,6 +193,7 @@ class Compiler(
if (plan.slots.isEmpty()) return emptyMap()
val result = LinkedHashMap<String, ForcedLocalSlotInfo>(plan.slots.size)
for ((name, entry) in plan.slots) {
if (externBindingNames.contains(name)) continue
result[name] = ForcedLocalSlotInfo(
index = entry.index,
isMutable = entry.isMutable,
@@ -581,7 +583,7 @@ class Compiler(
val baseNames = cls.directParents.map { it.className }
val nextFieldId = (fieldIds.values.maxOrNull() ?: -1) + 1
val nextMethodId = (methodIds.values.maxOrNull() ?: -1) + 1
return CompileClassInfo(name, fieldIds, methodIds, nextFieldId, nextMethodId, baseNames)
return CompileClassInfo(name, cls.logicalPackageName, fieldIds, methodIds, nextFieldId, nextMethodId, baseNames)
}
private data class BaseMemberIds(
@@ -885,6 +887,7 @@ class Compiler(
name = declaredName,
isMutable = false,
visibility = Visibility.Public,
actualExtern = false,
initializer = initStmt,
isTransient = false,
typeDecl = null,
@@ -1550,6 +1553,7 @@ class Compiler(
private data class CompileClassInfo(
val name: String,
val packageName: String?,
val fieldIds: Map<String, Int>,
val methodIds: Map<String, Int>,
val nextFieldId: Int,
@@ -1751,6 +1755,7 @@ class Compiler(
enumEntriesByName = enumEntriesByName,
callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
) as BytecodeStatement
unwrapped to bytecodeStmt.bytecodeFunction()
@@ -1957,6 +1962,9 @@ class Compiler(
val scopeIndex = slotPlanStack.indexOfLast { it.id == slotLoc.scopeId }
if (functionIndex >= 0 && scopeIndex >= functionIndex) return null
val modulePlan = moduleSlotPlan()
if (modulePlan != null && slotLoc.scopeId == modulePlan.id && externBindingNames.contains(name)) {
return null
}
if (useScopeSlots && modulePlan != null && slotLoc.scopeId == modulePlan.id) {
return null
}
@@ -2075,6 +2083,7 @@ class Compiler(
callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName,
externCallableNames = externCallableNames,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
)
}
@@ -2105,6 +2114,7 @@ class Compiler(
callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName,
externCallableNames = externCallableNames,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
)
}
@@ -2160,6 +2170,7 @@ class Compiler(
callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName,
externCallableNames = externCallableNames,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
)
}
@@ -2343,6 +2354,7 @@ class Compiler(
stmt.name,
stmt.isMutable,
stmt.visibility,
stmt.actualExtern,
init,
stmt.isTransient,
stmt.typeDecl,
@@ -4655,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
}
@@ -4718,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)
@@ -4732,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
@@ -6446,7 +6555,7 @@ class Compiler(
WhenStatement(value, cases, elseCase, whenPos)
} else {
// when { cond -> ... }
TODO("when without object is not yet implemented")
throw ScriptError(t.pos, "when without subject is not implemented")
}
return wrapBytecode(stmt)
}
@@ -6699,6 +6808,7 @@ class Compiler(
)
compileClassInfos[qualifiedName] = CompileClassInfo(
name = qualifiedName,
packageName = packageName,
fieldIds = fieldIds,
methodIds = methodIds,
nextFieldId = fieldIds.size,
@@ -6821,6 +6931,7 @@ class Compiler(
classCtx?.let { ctx ->
compileClassInfos[className] = CompileClassInfo(
name = className,
packageName = packageName,
fieldIds = ctx.memberFieldIds.toMap(),
methodIds = ctx.memberMethodIds.toMap(),
nextFieldId = ctx.nextFieldId,
@@ -6862,6 +6973,7 @@ class Compiler(
val baseIds = collectBaseMemberIds(baseSpecs.map { it.name })
compileClassInfos[className] = CompileClassInfo(
name = className,
packageName = packageName,
fieldIds = baseIds.fieldIds,
methodIds = baseIds.methodIds,
nextFieldId = baseIds.nextFieldId,
@@ -7110,6 +7222,7 @@ class Compiler(
}
compileClassInfos[qualifiedName] = CompileClassInfo(
name = qualifiedName,
packageName = packageName,
fieldIds = ctx.memberFieldIds.toMap(),
methodIds = ctx.memberMethodIds.toMap(),
nextFieldId = ctx.nextFieldId,
@@ -7125,6 +7238,7 @@ class Compiler(
classCtx?.let { ctx ->
compileClassInfos[qualifiedName] = CompileClassInfo(
name = qualifiedName,
packageName = packageName,
fieldIds = ctx.memberFieldIds.toMap(),
methodIds = ctx.memberMethodIds.toMap(),
nextFieldId = ctx.nextFieldId,
@@ -7211,6 +7325,7 @@ class Compiler(
}
compileClassInfos[qualifiedName] = CompileClassInfo(
name = qualifiedName,
packageName = packageName,
fieldIds = ctx.memberFieldIds.toMap(),
methodIds = ctx.memberMethodIds.toMap(),
nextFieldId = ctx.nextFieldId,
@@ -8390,7 +8505,7 @@ class Compiler(
is ImplicitThisMethodCallRef -> {
when (directRef.methodName()) {
"iterator" -> ObjIterator
"lazy" -> ObjLazyDelegate.type
"lazy" -> resolveClassByName("lazy") ?: inferMethodCallReturnClass(directRef.methodName())
else -> inferMethodCallReturnClass(directRef.methodName())
}
}
@@ -8409,7 +8524,7 @@ class Compiler(
when {
target is LocalSlotRef -> {
when (target.name) {
"lazy" -> ObjLazyDelegate.type
"lazy" -> resolveClassByName("lazy")
"iterator" -> ObjIterator
"flow" -> ObjFlow.type
"launch" -> ObjDeferred.type
@@ -8420,7 +8535,7 @@ class Compiler(
}
target is LocalVarRef -> {
when (target.name) {
"lazy" -> ObjLazyDelegate.type
"lazy" -> resolveClassByName("lazy")
"iterator" -> ObjIterator
"flow" -> ObjFlow.type
"launch" -> ObjDeferred.type
@@ -8459,7 +8574,9 @@ class Compiler(
?: unwrapDirectRef(initializer)?.let { inferObjClassFromRef(it) }
?: throw ScriptError(initializer.pos, "Delegate type must be known at compile time")
if (initClass !== delegateClass &&
initClass.logicalName != delegateClass.logicalName &&
!initClass.allParentsSet.contains(delegateClass) &&
!initClass.allParentsSet.any { it.logicalName == delegateClass.logicalName } &&
!initClass.allImplementingNames.contains(delegateClass.className)
) {
throw ScriptError(
@@ -8549,7 +8666,9 @@ class Compiler(
if (closedParent != null) {
throw ScriptError(Pos.builtIn, "can't inherit from closed class ${closedParent.className}")
}
ObjInstanceClass(info.name, *parents.toTypedArray())
ObjInstanceClass(info.name, *parents.toTypedArray()).apply {
logicalPackageNameOverride = info.packageName
}
}
if (stub is ObjInstanceClass) {
for ((fieldName, fieldId) in info.fieldIds) {
@@ -8893,7 +9012,7 @@ class Compiler(
val effectiveEqToken = if (isProperty) null else eqToken
// Register the local name at compile time so that subsequent identifiers can be emitted as fast locals
if (!isStatic && declaringClassNameCaptured == null) declareLocalName(name, isMutable)
if (!isStatic && declaringClassNameCaptured == null && !actualExtern) declareLocalName(name, isMutable)
val declKind = if (codeContexts.lastOrNull() is CodeContext.ClassBody) {
SymbolKind.MEMBER
} else {
@@ -8902,6 +9021,8 @@ class Compiler(
resolutionSink?.declareSymbol(name, declKind, isMutable, nameStartPos, isOverride = isOverride)
if (declKind == SymbolKind.MEMBER && extTypeName == null) {
(codeContexts.lastOrNull() as? CodeContext.ClassBody)?.declaredMembers?.add(name)
} else if (actualExtern) {
externBindingNames.add(name)
}
val isDelegate = if (isAbstract || actualExtern) {
@@ -8967,7 +9088,11 @@ class Compiler(
if (isDelegate && initialExpression != null) {
ensureDelegateType(initialExpression)
if (isMutable && resolveInitializerObjClass(initialExpression) == ObjLazyDelegate.type) {
val lazyClass = resolveClassByName("lazy")
if (isMutable &&
lazyClass != null &&
resolveInitializerObjClass(initialExpression)?.logicalName == lazyClass.logicalName
) {
throw ScriptError(initialExpression.pos, "lazy delegate is read-only")
}
}
@@ -9059,6 +9184,7 @@ class Compiler(
name,
isMutable,
visibility,
actualExtern,
initialExpression,
isTransient,
declaredType,
@@ -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
@@ -46,6 +48,28 @@ class Script(
// private val catchReturn: Boolean = false,
) : Statement() {
fun statements(): List<Statement> = statements
/**
* Explicitly apply this script's import/module bindings to [scope] without executing the script.
* This is intended for embedding scenarios where the host owns scope lifecycle and wants
* script-specific imports to be a separate, opt-in preparation step.
*/
suspend fun importInto(scope: Scope, seedScope: Scope = scope): Scope {
prepareScriptScope(scope, seedScope)
return scope
}
/**
* Create a fresh raw module scope and apply this script's import/module bindings to it.
* If [seedScope] is provided, its import provider is reused and seed-bound imports resolve from it.
*/
suspend fun instantiateModule(seedScope: Scope? = null, pos: Pos = this.pos): ModuleScope {
val provider = seedScope?.currentImportProvider ?: defaultImportManager
val module = provider.newModuleAt(pos)
prepareScriptScope(module, seedScope ?: module)
return module
}
override suspend fun execute(scope: Scope): Obj {
scope.pos = pos
val execScope = resolveModuleScope(scope) ?: scope
@@ -75,6 +99,19 @@ class Script(
return ObjVoid
}
private suspend fun prepareScriptScope(scope: Scope, seedScope: Scope) {
if (importBindings.isNotEmpty() || importedModules.isNotEmpty()) {
seedImportBindings(scope, seedScope)
}
if (moduleSlotPlan.isNotEmpty()) {
scope.applySlotPlan(moduleSlotPlan)
for (name in moduleSlotPlan.keys) {
val record = scope.objects[name] ?: scope.localBindings[name] ?: continue
scope.updateSlotFor(name, record)
}
}
}
private suspend fun seedModuleSlots(scope: Scope, seedScope: Scope) {
if (importBindings.isEmpty() && importedModules.isEmpty()) return
seedImportBindings(scope, seedScope)
@@ -505,10 +542,6 @@ class Script(
cachedValue
}
}
addFn("lazy") {
val builder = requireOnlyArg<Obj>()
ObjLazyDelegate(builder, requireScope())
}
addVoidFn("delay") {
val a = args.firstAndOnly()
when (a) {
@@ -696,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",
@@ -24,6 +24,7 @@ class VarDeclStatement(
val name: String,
val isMutable: Boolean,
val visibility: Visibility,
val actualExtern: Boolean,
val initializer: Statement?,
val isTransient: Boolean,
val typeDecl: TypeDecl?,
@@ -42,6 +42,7 @@ class BytecodeCompiler(
private val callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
private val callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
private val externCallableNames: Set<String> = emptySet(),
private val externBindingNames: Set<String> = emptySet(),
private val lambdaCaptureEntriesByRef: Map<ValueFnRef, List<LambdaCaptureEntry>> = emptyMap(),
) {
private val useScopeSlots: Boolean = allowedScopeNames != null || scopeSlotNameSet != null
@@ -1088,7 +1089,6 @@ class BytecodeCompiler(
private fun isDelegateClass(receiverClass: ObjClass): Boolean =
receiverClass.className == "Delegate" ||
receiverClass.className == "LazyDelegate" ||
receiverClass.implementingNames.contains("Delegate")
private fun operatorMemberName(op: BinOp): String? = when (op) {
@@ -4508,7 +4508,7 @@ class BytecodeCompiler(
val resolved = slotTypes[slot] ?: SlotType.UNKNOWN
return CompiledValue(slot, resolved)
}
if (useScopeSlots && allowedScopeNames?.contains(name) == true) {
if (useScopeSlots && isPreparedScopeName(name)) {
scopeSlotIndexByName[name]?.let { slot ->
val resolved = slotTypes[slot] ?: SlotType.UNKNOWN
return CompiledValue(slot, resolved)
@@ -8073,7 +8073,7 @@ class BytecodeCompiler(
slotInitClassByKey[ScopeSlotKey(scopeId, slotIndex)] = cls
}
}
if (allowLocalSlots && slotIndex != null && !shouldUseScopeSlotFor(scopeId)) {
if (allowLocalSlots && slotIndex != null && (stmt.actualExtern || !shouldUseScopeSlotFor(scopeId, stmt.name, isDelegated = false))) {
val key = ScopeSlotKey(scopeId, slotIndex)
declaredLocalKeys.add(key)
if (!localSlotInfoMap.containsKey(key)) {
@@ -8100,7 +8100,7 @@ class BytecodeCompiler(
val scopeId = stmt.spec.scopeId ?: 0
if (slotIndex != null) {
val key = ScopeSlotKey(scopeId, slotIndex)
if (allowLocalSlots && !shouldUseScopeSlotFor(scopeId)) {
if (allowLocalSlots && !shouldUseScopeSlotFor(scopeId, stmt.spec.name, isDelegated = false)) {
if (!localSlotInfoMap.containsKey(key)) {
localSlotInfoMap[key] = LocalSlotInfo(stmt.spec.name, isMutable = false, isDelegated = false)
}
@@ -8117,7 +8117,7 @@ class BytecodeCompiler(
is DelegatedVarDeclStatement -> {
val slotIndex = stmt.slotIndex
val scopeId = stmt.scopeId ?: 0
if (allowLocalSlots && slotIndex != null && !shouldUseScopeSlotFor(scopeId)) {
if (allowLocalSlots && slotIndex != null && !shouldUseScopeSlotFor(scopeId, stmt.name, isDelegated = true)) {
val key = ScopeSlotKey(scopeId, slotIndex)
declaredLocalKeys.add(key)
if (!localSlotInfoMap.containsKey(key)) {
@@ -8283,15 +8283,26 @@ class BytecodeCompiler(
private fun isModuleSlot(scopeId: Int, name: String?): Boolean {
if (moduleScopeId != null && scopeId != moduleScopeId) return false
val scopeNames = allowedScopeNames ?: scopeSlotNameSet
if (scopeNames == null || name == null) return false
return scopeNames.contains(name)
return isPreparedScopeName(name)
}
private fun shouldUseScopeSlotFor(scopeId: Int): Boolean {
return useScopeSlots && moduleScopeId != null && scopeId == moduleScopeId
}
private fun shouldUseScopeSlotFor(scopeId: Int, name: String, isDelegated: Boolean): Boolean {
if (moduleScopeId == null || scopeId != moduleScopeId) return false
if (isDelegated) return false
if (externBindingNames.contains(name)) return true
return useScopeSlots && isPreparedScopeName(name)
}
private fun isPreparedScopeName(name: String?): Boolean {
if (name == null) return false
if (scopeSlotNameSet?.contains(name) == true) return true
return allowedScopeNames?.contains(name) == true
}
private fun collectLoopVarNames(stmt: Statement) {
if (stmt is BytecodeStatement) {
collectLoopVarNames(stmt.original)
@@ -8400,8 +8411,9 @@ class BytecodeCompiler(
captureSlotKeys.add(key)
return
}
val forceScopeSlot = shouldUseScopeSlotFor(scopeId, ref.name, ref.isDelegated)
val isModuleSlot = if (ref.isDelegated) false else isModuleSlot(scopeId, ref.name)
if (allowLocalSlots && !isModuleSlot) {
if (allowLocalSlots && !isModuleSlot && !forceScopeSlot) {
if (!localSlotInfoMap.containsKey(key)) {
localSlotInfoMap[key] = LocalSlotInfo(ref.name, ref.isMutable, ref.isDelegated)
}
@@ -8448,8 +8460,9 @@ class BytecodeCompiler(
}
captureSlotKeys.add(key)
} else {
val forceScopeSlot = shouldUseScopeSlotFor(scopeId, target.name, target.isDelegated)
val isModuleSlot = if (target.isDelegated) false else isModuleSlot(scopeId, target.name)
if (allowLocalSlots && !isModuleSlot) {
if (allowLocalSlots && !isModuleSlot && !forceScopeSlot) {
if (!localSlotInfoMap.containsKey(key)) {
localSlotInfoMap[key] = LocalSlotInfo(target.name, target.isMutable, target.isDelegated)
}
@@ -87,6 +87,7 @@ class BytecodeStatement private constructor(
callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
externCallableNames: Set<String> = emptySet(),
externBindingNames: Set<String> = emptySet(),
lambdaCaptureEntriesByRef: Map<ValueFnRef, List<LambdaCaptureEntry>> = emptyMap(),
slotTypeDeclByScopeId: Map<Int, Map<Int, TypeDecl>> = emptyMap(),
): Statement {
@@ -122,6 +123,7 @@ class BytecodeStatement private constructor(
callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName,
externCallableNames = externCallableNames,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
)
val compiled = compiler.compileStatement(nameHint, statement)
@@ -236,6 +238,7 @@ class BytecodeStatement private constructor(
stmt.name,
stmt.isMutable,
stmt.visibility,
stmt.actualExtern,
stmt.initializer?.let { unwrapDeep(it) },
stmt.isTransient,
stmt.typeDecl,
@@ -2392,8 +2392,13 @@ class CmdDeclLocal(internal val constId: Int, internal val slot: Int) : Cmd() {
?: error("DECL_LOCAL expects LocalDecl at $constId")
if (slot < frame.fn.scopeSlotCount) {
val target = frame.scopeTarget(slot)
frame.ensureScopeSlot(target, slot)
val value = frame.slotToObj(slot).byValueCopy()
val index = frame.ensureScopeSlot(target, slot)
val raw = target.getSlotRecord(index).value
val value = when (raw) {
is FrameSlotRef -> raw.read()
is RecordSlotRef -> raw.read()
else -> raw
}.byValueCopy()
target.updateSlotFor(
decl.name,
ObjRecord(
@@ -3930,7 +3935,7 @@ class CmdFrame(
} else {
val raw = frame.getRawObj(localIndex)
if (raw == null && name != null) {
val record = scope.get(name)
val record = findNamedExistingRecord(scope, name)
if (record != null) {
val value = record.value
return@mapIndexed when (value) {
@@ -3939,6 +3944,12 @@ class CmdFrame(
else -> ObjRecord(value, isMutable)
}
}
if (hasNamedScopeBinding(scope, name)) {
throw ScriptError(
ensureScope().pos,
"captured binding '$name' is not available in the execution scope; prepare the script imports/module bindings explicitly"
)
}
}
when (raw) {
is FrameSlotRef -> ObjRecord(raw, isMutable)
@@ -3952,22 +3963,27 @@ class CmdFrame(
val target = moduleScope
val name = captureNames?.getOrNull(index)
if (name != null) {
target.tryGetLocalRecord(target, name, target.currentClassCtx)?.let { return@mapIndexed it }
target.getSlotIndexOf(name)?.let { return@mapIndexed target.getSlotRecord(it) }
target.get(name)?.let { return@mapIndexed it }
findNamedExistingRecord(target, name)?.let { return@mapIndexed it }
// Fallback to current scope in case the module scope isn't in the parent chain
// or doesn't carry the imported symbol yet.
scope.tryGetLocalRecord(scope, name, scope.currentClassCtx)?.let { return@mapIndexed it }
scope.get(name)?.let { return@mapIndexed it }
findNamedExistingRecord(scope, name)?.let { return@mapIndexed it }
}
if (slotId < target.slotCount) {
return@mapIndexed target.getSlotRecord(slotId)
val existing = target.getSlotRecord(slotId)
if (name == null || existing.value !== ObjUnset || hasResolvedNamedScopeBinding(target, name)) {
return@mapIndexed existing
}
}
if (name != null) {
target.applySlotPlan(mapOf(name to slotId))
return@mapIndexed target.getSlotRecord(slotId)
throw ScriptError(
ensureScope().pos,
"module capture '$name' is not available in the execution scope; prepare the script imports/module bindings explicitly"
)
}
error("Missing module capture slot $slotId")
throw ScriptError(
ensureScope().pos,
"missing module capture slot $slotId"
)
}
}
}
@@ -4546,7 +4562,7 @@ class CmdFrame(
return getScopeSlotValueAtAddr(addrSlot)
}
fun setAddrObj(addrSlot: Int, value: Obj) {
suspend fun setAddrObj(addrSlot: Int, value: Obj) {
setScopeSlotValueAtAddr(addrSlot, value)
}
@@ -4554,7 +4570,7 @@ class CmdFrame(
return getScopeSlotValueAtAddr(addrSlot).toLong()
}
fun setAddrInt(addrSlot: Int, value: Long) {
suspend fun setAddrInt(addrSlot: Int, value: Long) {
setScopeSlotValueAtAddr(addrSlot, ObjInt.of(value))
}
@@ -4562,7 +4578,7 @@ class CmdFrame(
return getScopeSlotValueAtAddr(addrSlot).toDouble()
}
fun setAddrReal(addrSlot: Int, value: Double) {
suspend fun setAddrReal(addrSlot: Int, value: Double) {
setScopeSlotValueAtAddr(addrSlot, ObjReal.of(value))
}
@@ -4570,7 +4586,7 @@ class CmdFrame(
return getScopeSlotValueAtAddr(addrSlot).toBool()
}
fun setAddrBool(addrSlot: Int, value: Boolean) {
suspend fun setAddrBool(addrSlot: Int, value: Boolean) {
setScopeSlotValueAtAddr(addrSlot, if (value) ObjTrue else ObjFalse)
}
@@ -4776,12 +4792,13 @@ class CmdFrame(
private suspend fun getScopeSlotValue(slot: Int): Obj {
val target = scopeTarget(slot)
val name = fn.scopeSlotNames[slot]
val hadNamedBinding = name != null && hasResolvedNamedScopeBinding(target, name)
val index = ensureScopeSlot(target, slot)
val record = target.getSlotRecord(index)
val direct = record.value
if (direct is FrameSlotRef) return direct.read()
if (direct is RecordSlotRef) return direct.read()
val name = fn.scopeSlotNames[slot]
if (name != null && record.memberName != null && record.memberName != name) {
val resolved = target.get(name)
if (resolved != null) {
@@ -4802,9 +4819,15 @@ class CmdFrame(
return direct
}
if (name == null) return record.value
val resolved = target.get(name) ?: return record.value
val resolved = target.get(name)
if (resolved == null) {
failMissingPreparedModuleBinding(slot, name, hadNamedBinding, record)
return record.value
}
if (resolved.value !== ObjUnset) {
target.updateSlotFor(name, resolved)
} else {
failMissingPreparedModuleBinding(slot, name, hadNamedBinding, resolved)
}
return resolved.value
}
@@ -4812,12 +4835,13 @@ class CmdFrame(
private suspend fun getScopeSlotValueAtAddr(addrSlot: Int): Obj {
val target = addrScopes[addrSlot] ?: error("Address slot $addrSlot is not resolved")
val index = addrIndices[addrSlot]
val slotId = addrScopeSlots[addrSlot]
val name = fn.scopeSlotNames.getOrNull(slotId)
val hadNamedBinding = name != null && hasResolvedNamedScopeBinding(target, name)
val record = target.getSlotRecord(index)
val direct = record.value
if (direct is FrameSlotRef) return direct.read()
if (direct is RecordSlotRef) return direct.read()
val slotId = addrScopeSlots[addrSlot]
val name = fn.scopeSlotNames.getOrNull(slotId)
if (name != null && record.memberName != null && record.memberName != name) {
val resolved = target.get(name)
if (resolved != null) {
@@ -4838,16 +4862,29 @@ class CmdFrame(
return direct
}
if (name == null) return record.value
val resolved = target.get(name) ?: return record.value
val resolved = target.get(name)
if (resolved == null) {
failMissingPreparedModuleBinding(slotId, name, hadNamedBinding, record)
return record.value
}
if (resolved.value !== ObjUnset) {
target.updateSlotFor(name, resolved)
} else {
failMissingPreparedModuleBinding(slotId, name, hadNamedBinding, resolved)
}
return resolved.value
}
private fun setScopeSlotValueAtAddr(addrSlot: Int, value: Obj) {
private suspend fun setScopeSlotValueAtAddr(addrSlot: Int, value: Obj) {
val target = addrScopes[addrSlot] ?: error("Address slot $addrSlot is not resolved")
val index = addrIndices[addrSlot]
val record = target.getSlotRecord(index)
val slotId = addrScopeSlots[addrSlot]
val name = fn.scopeSlotNames.getOrNull(slotId)
if (name != null && (record.type == ObjRecord.Type.Delegated || record.type == ObjRecord.Type.Property || record.value is ObjProperty)) {
target.assign(record, name, value)
return
}
target.setSlotValue(index, value)
}
@@ -4875,6 +4912,41 @@ class CmdFrame(
return index
}
private fun hasNamedScopeBinding(target: Scope, name: String): Boolean {
if (target.tryGetLocalRecord(target, name, target.currentClassCtx) != null) return true
if (target.getSlotIndexOf(name) != null) return true
if (target.get(name) != null) return true
return false
}
private fun hasResolvedNamedScopeBinding(target: Scope, name: String): Boolean =
findNamedExistingRecord(target, name) != null
private fun findNamedExistingRecord(target: Scope, name: String): ObjRecord? {
target.tryGetLocalRecord(target, name, target.currentClassCtx)?.let { return it }
target.get(name)?.let { record ->
if (record.value !== ObjUnset || record.type == ObjRecord.Type.Delegated || record.type == ObjRecord.Type.Property) {
return record
}
}
return null
}
private fun failMissingPreparedModuleBinding(
slot: Int,
name: String,
hadNamedBinding: Boolean,
record: ObjRecord
) {
if (hadNamedBinding) return
if (record.value !== ObjUnset) return
if (fn.scopeSlotIsModule.getOrNull(slot) != true) return
throw ScriptError(
ensureScope().pos,
"module binding '$name' is not available in the execution scope; prepare the script imports/module bindings explicitly"
)
}
private fun ensureLocalMutable(localIndex: Int) {
val name = fn.localSlotNames.getOrNull(localIndex) ?: return
val isMutable = fn.localSlotMutables.getOrNull(localIndex) ?: true
@@ -36,7 +36,7 @@ internal suspend fun seedFrameLocalsFromScope(frame: CmdFrame, scope: Scope) {
} else {
record.value
}
if (value is net.sergeych.lyng.FrameSlotRef && value.refersTo(frame.frame, base + i)) {
if (value is net.sergeych.lyng.FrameSlotRef && value.refersTo(frame.frame, i)) {
continue
}
frame.setObjUnchecked(base + i, value)
@@ -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)
}
)
}
}
@@ -106,10 +106,20 @@ open class ObjClass(
vararg parents: ObjClass,
) : Obj() {
private fun declaringModulePackageName(scope: Scope?): String? {
var current = scope
while (current != null) {
if (current is ModuleScope) return current.packageName
current = current.parent
}
return null
}
var isAnonymous: Boolean = false
var isAbstract: Boolean = false
var isClosed: Boolean = false
var logicalPackageNameOverride: String? = null
// Stable identity and simple structural version for PICs
val classId: Long = ClassIdGen.nextId()
@@ -169,6 +179,16 @@ open class ObjClass(
*/
var classScope: Scope? = null
/**
* Stable logical identity for class matching across separately instantiated modules.
* Uses declaring module package plus class name when available.
*/
val logicalPackageName: String?
get() = logicalPackageNameOverride ?: declaringModulePackageName(classScope)
val logicalName: String
get() = logicalPackageName?.let { "$it::$className" } ?: className
/** Direct parents in declaration order (kept deterministic). */
val directParents: List<ObjClass> = parents.toList()
@@ -129,6 +129,7 @@ open class ObjDynamic(var readCallback: Obj? = null, var writeCallback: Obj? = n
}
val type = object : ObjClass("Delegate") {}.apply {
logicalPackageNameOverride = "lyng.stdlib"
addFn("getValue") { raiseError("Delegate.getValue is not implemented") }
addFn("setValue") { raiseError("Delegate.setValue is not implemented") }
addFn("invoke") { raiseError("Delegate.invoke is not implemented") }
@@ -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.
@@ -1,86 +0,0 @@
/*
* 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 net.sergeych.lyng.Arguments
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Statement
import net.sergeych.lyng.Visibility
import net.sergeych.lyng.executeBytecodeWithSeed
/**
* Lazy delegate used by `val x by lazy { ... }`.
*/
class ObjLazyDelegate(
private val builder: Obj,
private val capturedScope: Scope,
) : Obj() {
override val objClass: ObjClass = type
private var calculated = false
private var cachedValue: Obj = ObjVoid
override suspend fun invokeInstanceMethod(
scope: Scope,
name: String,
args: Arguments,
onNotFoundResult: (suspend () -> Obj?)?,
): Obj {
return when (name) {
"getValue" -> {
if (!calculated) {
val callScope = capturedScope.createChildScope(capturedScope.pos, args = Arguments.EMPTY)
cachedValue = if (builder is Statement) {
executeBytecodeWithSeed(callScope, builder, "lazy delegate")
} else {
builder.callOn(callScope)
}
calculated = true
}
cachedValue
}
"setValue" -> scope.raiseIllegalAssignment("lazy delegate is read-only")
else -> super.invokeInstanceMethod(scope, name, args, onNotFoundResult)
}
}
companion object {
val type = ObjClass("LazyDelegate").apply {
implementingNames.add("Delegate")
createField(
"getValue",
ObjNull,
isMutable = false,
visibility = Visibility.Public,
pos = Pos.builtIn,
declaringClass = this,
type = ObjRecord.Type.Fun
)
createField(
"setValue",
ObjNull,
isMutable = false,
visibility = Visibility.Public,
pos = Pos.builtIn,
declaringClass = this,
type = ObjRecord.Type.Fun
)
}
}
}
@@ -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,88 @@
/*
* Copyright 2026 Sergey S. Chernov
*
* 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.
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Script
import net.sergeych.lyng.ScriptError
import net.sergeych.lyng.Source
import net.sergeych.lyng.asFacade
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.toInt
import net.sergeych.lyng.pacman.ImportManager
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class CompilerVmReviewRegressionTest {
@Test
fun missingModuleCaptureFailsFastInsteadOfBecomingUnset() = runTest {
val manager = ImportManager()
manager.addTextPackages(
"""
package foo
val answer = 42
""".trimIndent()
)
val script = Compiler.compile(
Source(
"<missing-module-capture>",
"""
import foo
fun make() = { answer }
make()
""".trimIndent()
),
manager
)
val prepared = manager.newModule()
script.importInto(prepared)
val preparedLambda = script.execute(prepared)
assertEquals(42, prepared.asFacade().call(preparedLambda).toInt())
val rawModule = manager.newModule()
val hostScope = Scope(parent = rawModule, thisObj = ObjString("receiver"))
val lambda = script.execute(hostScope)
val ex = assertFailsWith<ScriptError> {
hostScope.asFacade().call(lambda)
}
assertContains(ex.errorMessage, "module binding 'answer'")
}
@Test
fun subjectlessWhenReportsScriptError() = runTest {
val ex = assertFailsWith<ScriptError> {
Compiler.compile(
Source(
"<when-without-subject>",
"""
when {
true -> 1
}
""".trimIndent()
),
Script.defaultImportManager
)
}
assertContains(ex.errorMessage, "when without subject")
}
}
@@ -0,0 +1,52 @@
/*
* 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 net.sergeych.lyng.bridge.bindGlobalVar
import net.sergeych.lyng.bridge.globalBinder
import kotlin.test.Test
import kotlin.test.assertEquals
class GlobalPropertyCaptureRegressionTest {
@Test
fun externGlobalVarAssignmentInsideFunctionShouldCallBoundSetter() = runTest {
val scope = Script.newScope()
var x = 1.0
scope.eval(
"""
extern var X: Real
fun main() {
X = X + 1.0
}
""".trimIndent()
)
scope.globalBinder().bindGlobalVar(
name = "X",
get = { x },
set = { x = it }
)
scope.eval("main()")
assertEquals(2.0, x, "bound extern var should stay live inside function bodies")
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Sergey S. Chernov
*
* 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.
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Script
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.toInt
import net.sergeych.lyng.pacman.ImportManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ScriptImportPreparationTest {
@Test
fun scriptImportIntoExplicitlyPreparesExistingScope() = runTest {
val manager = ImportManager()
manager.addTextPackages(
"""
package foo
val answer = 42
""".trimIndent()
)
val script = Compiler.compile(
Source(
"<prepare-scope>",
"""
import foo
answer
""".trimIndent()
),
manager
)
val scope = manager.newModule()
assertNull(scope["answer"])
script.importInto(scope)
val record = assertNotNull(scope["answer"])
assertEquals(42, scope.resolve(record, "answer").toInt())
}
@Test
fun scriptInstantiateModuleUsesSeedScopeImportProvider() = runTest {
val manager = ImportManager()
manager.addTextPackages(
"""
package foo
val answer = 42
""".trimIndent()
)
val script = Compiler.compile(
Source(
"<instantiate-module>",
"""
import foo
answer
""".trimIndent()
),
manager
)
val seedScope = manager.newModule()
val module = script.instantiateModule(seedScope)
val record = assertNotNull(module["answer"])
assertEquals(42, module.resolve(record, "answer").toInt())
}
}
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Sergey S. Chernov
*
* 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.
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.FrameSlotRef
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.bytecode.BytecodeConst
import net.sergeych.lyng.bytecode.CmdFrame
import net.sergeych.lyng.bytecode.CmdFunction
import net.sergeych.lyng.bytecode.CmdVm
import net.sergeych.lyng.bytecode.seedFrameLocalsFromScope
import kotlin.test.Test
import kotlin.test.assertNull
class SeedLocalsRegressionTest {
@Test
fun seedFrameLocalsSkipsSelfReferentialFrameSlotRef() = runTest {
val fn = CmdFunction(
name = "seed-self-ref",
localCount = 1,
addrCount = 0,
returnLabels = emptySet(),
scopeSlotCount = 1,
scopeSlotIndices = intArrayOf(0),
scopeSlotNames = arrayOf(null),
scopeSlotIsModule = booleanArrayOf(false),
localSlotNames = arrayOf("x"),
localSlotMutables = booleanArrayOf(true),
localSlotDelegated = booleanArrayOf(false),
localSlotCaptures = booleanArrayOf(false),
constants = listOf(BytecodeConst.IntVal(0)),
cmds = emptyArray(),
posByIp = emptyArray()
)
val scope = Scope(null, pos = Pos.builtIn)
val record = scope.addConst("x", net.sergeych.lyng.obj.ObjInt.of(1))
val frame = CmdFrame(CmdVm(), fn, scope, emptyList())
record.value = FrameSlotRef(frame.frame, 0)
scope.updateSlotFor("x", record)
seedFrameLocalsFromScope(frame, scope)
assertNull(frame.frame.getRawObj(0))
}
}
@@ -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()
)
}
}
@@ -212,6 +212,96 @@ class DelegationTest {
assertTrue(badThrown)
}
@Test
fun testPureLyngLazyPreservesReceiverAndClosure() = runTest {
val scope = Script.newScope()
scope.eval(
"""
val GLOBAL_NUMBERS = [1,2,3]
class PureLazy<T,ThisRefType=Object>(creatorParam: ThisRefType.()->T) : Delegate<T,ThisRefType> {
private val creator: ThisRefType.()->T = creatorParam
private var value = Unset
override fun bind(name: String, access, thisRef: ThisRefType): Object = this
override fun getValue(thisRef: ThisRefType, name: String): T {
if (value == Unset)
value = creator(thisRef)
value as T
}
}
fun pureLazy<T,ThisRefType=Object>(creator: ThisRefType.()->T): Delegate<T,ThisRefType> = PureLazy(creator)
class A {
val numbers = [1,2,3]
val fromThis: List by pureLazy { this.numbers }
val fromScope: List by pureLazy { GLOBAL_NUMBERS }
}
class B {
val a: A by pureLazy { A() }
val test: List by pureLazy { (a as A).fromThis + [4] }
}
assertEquals([1,2,3], A().fromThis)
assertEquals([1,2,3], A().fromScope)
assertEquals([1,2,3,4], B().test)
""".trimIndent()
)
}
@Test
fun testImportedPureLyngLazyPreservesReceiverAndClosure() = runTest {
val scope = Script.newScope()
scope.importManager.addTextPackages(
"""
package repro.lazy
import lyng.stdlib
class PureLazy<T,ThisRefType=Object>(creatorParam: ThisRefType.()->T) : Delegate<T,ThisRefType> {
private val creator: ThisRefType.()->T = creatorParam
private var value = Unset
override fun bind(name: String, access: DelegateAccess, thisRef: ThisRefType): Object {
if (access != DelegateAccess.Val) throw "lazy delegate can only be used with 'val'"
this
}
override fun getValue(thisRef: ThisRefType, name: String): T {
if (value == Unset)
value = with(thisRef, creator)
value as T
}
}
""".trimIndent()
)
scope.eval(
"""
import repro.lazy
val GLOBAL_NUMBERS = [1,2,3]
class A {
val numbers = [1,2,3]
val fromThis: List by PureLazy { this.numbers }
val fromScope: List by PureLazy { GLOBAL_NUMBERS }
}
class B {
val a: A by PureLazy { A() }
val test: List by PureLazy { (a as A).fromThis + [4] }
}
assertEquals([1,2,3], A().fromThis)
assertEquals([1,2,3], A().fromScope)
assertEquals([1,2,3,4], B().test)
""".trimIndent()
)
}
@Test
fun testLazyIsDelegate() = runTest {
eval("""
@@ -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()
)
}
}
+1 -2
View File
@@ -421,7 +421,6 @@ fun with<T,R>(self: T, block: T.()->R): R {
Can only be used with 'val' properties.
*/
class lazy<T,ThisRefType=Object>(creatorParam: ThisRefType.()->T) : Delegate<T,ThisRefType> {
private val creator: ThisRefType.()->T = creatorParam
private var value = Unset
override fun bind(name: String, access: DelegateAccess, thisRef: ThisRefType): Object {
@@ -431,7 +430,7 @@ class lazy<T,ThisRefType=Object>(creatorParam: ThisRefType.()->T) : Delegate<T,T
override fun getValue(thisRef: ThisRefType, name: String): T {
if (value == Unset)
value = with(thisRef,creator)
value = with(thisRef, creatorParam)
value as T
}
}
@@ -0,0 +1,69 @@
# :lynglib Compiler and VM Review (2026-03-26)
## Scope
- Reviewed `:lynglib` compiler and bytecode VM paths, focusing on compile/execution correctness.
- Read core files around `Compiler`, `Script`, `BytecodeCompiler`, `CmdRuntime`, `Scope`, `ModuleScope`, and capture/slot plumbing.
- Ran `./gradlew :lynglib:jvmTest` on 2026-03-26: PASS.
## Findings
### 1. High: local seeding uses the wrong slot index when checking for self-referential `FrameSlotRef`
- Status: fixed in worktree on 2026-03-26; covered by `SeedLocalsRegressionTest`.
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/SeedLocals.kt:39`
- The self-reference guard compares `FrameSlotRef` against `base + i`, but `FrameSlotRef` stores the underlying `BytecodeFrame` local slot index, not the VM absolute slot id.
- The same slot is then written using `setObjUnchecked(base + i, value)` at `SeedLocals.kt:42`, which means a self-reference is not filtered out before being reinserted.
- Impact: this can seed a local with a reference to itself, creating recursive `FrameSlotRef` chains. Reads then recurse through `BytecodeFrame.getObj()` / `FrameSlotRef.read()` instead of resolving to a value, which is a realistic path to stack overflow or non-terminating execution in bytecode-only helper execution (`executeBytecodeWithSeed`).
- Why this looks real: the equivalent check in `CmdFrame.applyCaptureRecords()` uses the local index directly (`CmdRuntime.kt:3867`), and `Script.seedModuleLocals()` also compares with the local index, not `scopeSlotCount + i` (`Script.kt:109`).
- Suggested fix: compare with `i`, not `base + i`, and add a regression test around `executeBytecodeWithSeed` seeding a scope that already contains a `FrameSlotRef` to the same local.
### 2. Design note: script-specific import/module preparation should stay explicit, not hidden in `execute(scope)`
- Status: resolved by API addition, without changing `Script.execute(scope)` semantics.
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt:51-57`
- `shouldSeedModule` is only true when `execScope` is a `ModuleScope` or `thisObj === ObjVoid`.
- If a compiled script is executed against a non-module scope with a real receiver object, `seedModuleSlots()` is skipped entirely, so script imports and explicit module bindings are never installed into `moduleTarget`.
- Original concern: top-level bytecode that expects imported names or module globals can see different behavior depending on whether the host prepared the target scope explicitly.
- Resolution taken: do **not** change `Script.execute(scope)`, because in this codebase it is expected to run on exactly the provided scope and many embedding flows already rely on explicit scope setup via `Script.newScope()`, `Scope.eval(...)`, `addFn(...)`, `addConst(...)`, and direct import-manager configuration.
- New explicit APIs added instead:
- `Script.importInto(scope, seedScope = scope)`
- `Script.instantiateModule(seedScope = null, pos = script.pos)`
- This keeps existing execution behavior stable while giving hosts an opt-in way to apply a script's import/module bindings when they actually want that preparation step.
- Coverage added: `ScriptImportPreparationTest`.
### 3. Medium: missing module captures are silently converted into fresh `Unset` slots
- Status: fixed in worktree on 2026-03-27; covered by `CompilerVmReviewRegressionTest`.
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt:3950-3970`
- In `CmdFrame.buildCaptureRecords()`, the module-capture path first tries several lookups. If the requested `slotId` is missing but the capture has a `name`, it calls `target.applySlotPlan(mapOf(name to slotId))` and immediately returns `target.getSlotRecord(slotId)`.
- That record is a newly created placeholder from `Scope.applySlotPlan()` (`Scope.kt:460-471`) and defaults to `ObjUnset`.
- Impact: a compiler/runtime disagreement in capture resolution is masked as a normal capture of `Unset`, so the failure moves far away from closure creation and becomes data corruption or an unrelated later exception. This will be difficult to debug when it happens.
- Resolution taken: the VM now refuses to treat synthetic placeholder slots as real module/captured bindings. Reads and capture construction fail with a source-positioned `ScriptError` when the execution scope was not prepared with the script's required module/import bindings.
### 4. Medium: subject-less `when { ... }` still crashes through a raw Kotlin `TODO`
- Status: fixed in worktree on 2026-03-27; covered by `CompilerVmReviewRegressionTest`.
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt:6447-6449`
- The unsupported branch uses `TODO("when without object is not yet implemented")`.
- Current docs explicitly say subject-less `when` is not implemented, so the language limitation itself is documented. The problem is the failure mode: the compiler throws a raw Kotlin `NotImplementedError` instead of a normal `ScriptError` or a feature diagnostic.
- Impact: IDE/embedding callers get an implementation exception rather than a source-positioned language error, which is especially bad across non-JVM targets and for editor tooling.
- Resolution taken: the parser now throws a normal `ScriptError` at the `when` position with an explicit "when without subject is not implemented" message.
## Risks Worth Checking Next
### 5. Medium risk: module frame growth only retargets records stored in `objects`/`localBindings`
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/ModuleScope.kt:39-67`
- When `ensureModuleFrame()` replaces the old `BytecodeFrame` with a larger one, it retargets `FrameSlotRef`s only in `objects` and `localBindings`.
- Existing closures/capture records that already hold `FrameSlotRef(oldFrame, slot)` are not retargeted here.
- Impact: if a module scope survives across recompilation/re-execution with a larger local frame, previously exported closures can keep reading stale values from the old frame instance.
- Confidence is lower because this depends on module reuse across differing compiled shapes, but the reference-retargeting logic is clearly incomplete for that scenario.
- Suggested check: add a regression covering module re-execution with increased local count and an exported closure captured before the resize.
## Test Status
- `./gradlew :lynglib:jvmTest` passed during this review.
- `./gradlew :lynglib:jvmTest --tests ScriptImportPreparationTest --tests SeedLocalsRegressionTest` passed after the fixes/API additions.
- `./gradlew :lynglib:jvmTest --tests CompilerVmReviewRegressionTest --tests ScriptImportPreparationTest --tests SeedLocalsRegressionTest` passed after fixing findings 3 and 4.
- Finding 1 is covered directly; finding 2 is covered by explicit preparation API tests.
## Suggested Fix Order
1. Fix finding 1 first: it is a concrete slot-index bug with likely recursive failure modes. Done.
2. Keep `Script.execute(scope)` semantics stable and use explicit preparation APIs where script-owned import/module setup is needed. Done.
3. Tighten finding 3 next: fail fast on capture mismatches. Done.
4. Replace the raw `TODO` in finding 4 so unsupported syntax produces normal diagnostics. Done.
5. Decide whether finding 5 matters for current module-reload workflows; add a regression before changing behavior.
+46
View File
@@ -0,0 +1,46 @@
WASM использует стековую модель памяти. Lyng стеки не использует. В чем разница?
Стек это очень древняя структура, была придумана в 1955 (магазинная память Бауэра и Самельсона), и приняла современную форму в 1960, благодаря Барбаре Лисокв, вполседствии лауреата премии Тьюринга, которого, в свою очередь, за выдающийся вклад в развитие IT кастрировали, формально, за гомосексуализм, но в британии, где на трех джентльменов приходилось тогда, и приходится и теперь, четыре гомосексуала, все понимают, что это был просто предлог.
Стек был прекрасен в 60е годы, когда IBM планировала продавать по компьютеру в год в лучшем случае, а за идеи о микропроцессорах или ядрах можно было устроиться только пациентом психушки. Стек это заранее жестко выделенная область памяти, которая обычно вообще не используется, и по ней в одну сторону растет используемая область, которая хранит локальные переменные, адреса возврата и иногда состояние процессора.
Она крайне плохо подходит для многозадачности. Представь себе что у тебя есть wasm процесс, со своим стеком, и ему надо переключиться, остановив текущий поток исполнения (например, он ждет ответа от сети). Тогда тебе придется создать _новый стек_ — пока еще пустой, переключиться на него и исполнять там другую задачу. Сколько потоков - столько и стеков. И все они по большей части не используются — там просто запас памяти "на вырост", если исполнение потребует. А оценить заранее адекватно почти невозможно, дают с запасом.
Далее, васм использует фиксированную память выделенную на процесс. Ее тоже приходится брать "с запасом", так как если по ходу не хватит, процесс вылетит. И она тоже в изрядной части пустая. Ну и назасладочку, васм был задуман однозадачным, и все попытки туда хотя бы треды засунуть, получаются очень кривыми.
Линг использует фреймы и сопрограммы. Линг вообще треды не использует. Основная команда исполнения программы на линге - сопрограмма (coroutine), ей не нужен стейк, она им не пользуется. Вместо этого каждый вызов в линге, грубо говоря, это вызов сопрограммы. Для него формируется фрейм (не на стеке, а в динамической памяти!), которого гарантировано достаточно для исполнения собственно кода. Он заменяет собой "стековый фрейм", но его размер известен заранее, и его можно распределять и освобождать проще, как обычную динамическую память.
Далее, линг использует ту же динамическую память, что и его родительская платформа. Если он работает на Java, то использует память со сборкой мусора JVM, на Javascript - память машины JS, в нативных машинах - специальнй менеджер памяти со сборкой мусора, от Kotlin Native (довольно хороший и очень быстро развивающийся). В результате, запустить одновременно десять, или сто тысяч программ на лигне, которые будут исполняться конкурентно, вполне реально. Просто попытка запустить 10к васм-машин на v8 или другом движке скорее всего убьет систему, да и 10 тысяч тредов редко какой сервер приложению даст. Огромный перерасход ресурсов и тормоза.
Далее. Васм СТЕКОВАЯ машина со стековыми командами, он без стека вообще не может. Посмотрим как он считает примитивное выражение:
```wasm
(module
;; Экспортируем функцию, чтобы её можно было вызвать из JS
(func (export "calc") (param $a i32) (param $b i32) (param $c i32) (param $d i32) (result i32)
;; --- вычисление (a + b) / c * d ---
local.get $a ;; помещаем a на стек
local.get $b ;; помещаем b на стек
i32.add ;; складываем: стек содержит (a+b)
local.get $c ;; помещаем c
i32.div_s ;; знаковое деление: стек содержит ((a+b) / c)
local.get $d ;; помещаем d
i32.mul ;; умножение: стек содержит (((a+b)/c) * d)
;; результат остаётся на стеке — это возвращаемое значение функции
)
)
```
На ВМ Линга нет стека, он трехадресный универсальный ассемблер, если можно так сказать:
```
add_int s1, s2 -> s5 // a + b -> s5
div_int s5, s3 -> s5 // s5 -> s5 / c
mul_int s5, s4 -> s5 // s5 -> s5 * d
return s5
```
Этот код крайне эффективно реализуется на любом реальном процессоре, и хорошо оптимизируется. Исполняется он без всякого стека.
+21
View File
@@ -0,0 +1,21 @@
Почему не WASM?
- wasm очень громоздкий и неудобный для разработки, он проектировался очень давно и с другими целями, так что сейчас его безуспешно пытаются "перезаточить" в современные области, но он поддается с трудом. Компилировать в wasm была бы задача по сложности превосходящая весь проект, а результат был бы уныл: мы и так имеем все достоинсва wasm, полезные для Lyng, но не имеем многих его недостатков.
Виртуалка lyng легкая, и доступна на всех платформах уже сейчас, а вот прикрутить скажем wasm машину к котлину на сервере, чтобы она работала как сопрограммы первого класса вместе с котлинскими, задача сейчас неразрешимая.
То есть, преимущества wasm что он доступен на многих платформах, для нас неважно - мы и так на тех же платформах имеем полную поддержку.
- Lyng VM изначально заточена на исполнение именно сопрограмм со сборкой мусора. Эти два ключевых механизма линга практически отсутствуют в wasm, их мучительно прикручивают как расширения, и они довольно слабо поддерживаются.
- LyngVM заточен на исполнение программ с гибридной ООП-ФП моделью, она хорошо поддерживает множественное наследование, делегацию, работу со сложными списками аргументов (ФП фичи, подстановки, деструктурирование), на васме это очень непросто написать, будет медленно и громоздко.
- одна из целей Lyng была получить быстрое безопасное и очень мощное скриптовое решение для вклучения в проекты на Java/Kotlin multiplatform. На wasm это в принципе невозможно - его включение это кошмар, он громоздкий. К тому же требует компиляции, а Lyng работает с JIT (исполняет прямо исходник, компилируя его на лету, как JS), и может использоваться как скрипт (wasm не может)
- типы данных которые мы используем в Lyng в WASM отсутстсвтуют, их бы пришлось добавлять
В результате если бы мы компилировали Lyng->WASM мы бы получили огромный и медленный код. Для которого потребовалась бы огромная wasm VM. Практического смысла в этом нет.
Если же ты рассматриваешь идею в единой платформе использовать разные языки, а wasm как промежуточный язых совместимости, то это собственно противоречит идее использовать общую кодовую базу и интерфейсы. Разные языки имеют несовпадающие, несовместимые интерфейсы вызовов, так что приходится писать руками биндинги, которые не добавляют скорости и добавляют ошибки, к тому же, разные языки предоставляют часто вообще несовместимые модели программирования (например прототипы в JS, множественное наследование С++ или Линга и кошмар на улице вязов от руста, в владением ссылками).
Платформ на васме полно, и там и ловить нечего, и неинтересно. Я же предлагаю вылезти в другой класс. Навеяно реальным опоытом переносов контрактов из старого золота в мольтпей, которое получается на удивление хорошо
+91 -77
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")