diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b302d12 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +## Changelog + +### Unreleased + +- Multiple Inheritance (MI) completed and enabled by default: + - Active C3 Method Resolution Order (MRO) for deterministic, monotonic lookup across complex hierarchies and diamonds. + - Qualified dispatch: + - `this@Type.member(...)` inside class bodies starts lookup at the specified ancestor. + - Cast-based disambiguation: `(expr as Type).member(...)`, `(expr as? Type)?.member(...)` (works with existing safe-call `?.`). + - Field inheritance (`val`/`var`) under MI: + - Instance storage is disambiguated per declaring class; unqualified read/write resolves to the first match in MRO. + - Qualified read/write targets the chosen ancestor’s storage. + - Constructors and initialization: + - Direct bases are initialized left-to-right; each ancestor is initialized at most once (diamond-safe de-duplication). + - Header-specified constructor arguments are passed to direct bases. + - Visibility enforcement under MI: + - `private` visible only inside the declaring class body. + - `protected` visible inside the declaring class and any of its transitive subclasses; unrelated contexts cannot access it (qualification/casts do not bypass). + - Diagnostics improvements: + - Missing member/field messages include receiver class and linearization order; hints for `this@Type` or casts when helpful. + - Invalid `this@Type` reports that the qualifier is not an ancestor and shows the receiver lineage. + - `as`/`as?` cast errors include actual and target type names. + +- Documentation updated (docs/OOP.md and tutorial quick-start) to reflect MI with active C3 MRO. + +Notes: +- Existing single-inheritance code continues to work; resolution reduces to the single base. +- If code previously relied on non-deterministic parent set iteration, C3 MRO provides a predictable order; disambiguate explicitly if needed using `this@Type`/casts. diff --git a/README.md b/README.md index 93593e1..1e91177 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,8 @@ Ready features: ### Under way: -- [ ] regular exceptions + extended `when` -- [ ] multiple inheritance for user classes +- [x] regular exceptions + extended `when` +- [x] multiple inheritance for user classes - [ ] site with integrated interpreter to give a try - [ ] kotlin part public API good docs, integration focused diff --git a/docs/OOP.md b/docs/OOP.md index 5166bf4..62169ec 100644 --- a/docs/OOP.md +++ b/docs/OOP.md @@ -71,6 +71,99 @@ Functions defined inside a class body are methods, and unless declared void >>> void +## Multiple Inheritance (MI) + +Lyng supports declaring a class with multiple direct base classes. The syntax is: + +``` +class Foo(val a) { + var tag = "F" + fun runA() { "ResultA:" + a } + fun common() { "CommonA" } + private fun privateInFoo() {} + protected fun protectedInFoo() {} +} + +class Bar(val b) { + var tag = "B" + fun runB() { "ResultB:" + b } + fun common() { "CommonB" } +} + +// Multiple inheritance with per‑base constructor arguments +class FooBar(a, b) : Foo(a), Bar(b) { + // You can disambiguate via qualified this or casts + fun fromFoo() { this@Foo.common() } + fun fromBar() { this@Bar.common() } +} + +val fb = FooBar(1, 2) +assertEquals("ResultA:1", fb.runA()) +assertEquals("ResultB:2", fb.runB()) +// Unqualified ambiguous member resolves to the first base (leftmost) +assertEquals("CommonA", fb.common()) +// Disambiguation via casts +assertEquals("CommonB", (fb as Bar).common()) +assertEquals("CommonA", (fb as Foo).common()) + +// Field inheritance with name collisions +assertEquals("F", fb.tag) // unqualified: leftmost base +assertEquals("F", (fb as Foo).tag) // qualified read: Foo.tag +assertEquals("B", (fb as Bar).tag) // qualified read: Bar.tag + +fb.tag = "X" // unqualified write updates leftmost base +assertEquals("X", (fb as Foo).tag) +assertEquals("B", (fb as Bar).tag) + +(fb as Bar).tag = "Y" // qualified write updates Bar.tag +assertEquals("X", (fb as Foo).tag) +assertEquals("Y", (fb as Bar).tag) +``` + +Key rules and features: + +- Syntax + - `class Derived(args) : Base1(b1Args), Base2(b2Args)` + - Each direct base may receive constructor arguments specified in the header. Only direct bases receive header args; indirect bases must either be default‑constructible or receive their args through their direct child (future extensions may add more control). + +- Resolution order (C3 MRO — active) + - Member lookup is deterministic and follows C3 linearization (Python‑like), which provides a monotonic, predictable order for complex hierarchies and diamonds. + - Intuition: for `class D() : B(), C()` where `B()` and `C()` both derive from `A()`, the C3 order is `D → B → C → A`. + - The first visible match along this order wins. + +- Qualified dispatch + - Inside a class body, use `this@Type.member(...)` to start lookup at the specified ancestor. + - For arbitrary receivers, use casts: `(expr as Type).member(...)` or `(expr as? Type)?.member(...)` (safe‑call `?.` is already available in Lyng). + - Qualified access does not relax visibility. + +- Field inheritance (`val`/`var`) and collisions + - Instance storage is kept per declaring class, internally disambiguated; unqualified read/write resolves to the first match in the resolution order (leftmost base). + - Qualified read/write (via `this@Type` or casts) targets the chosen ancestor’s storage. + - `val` remains read‑only; attempting to write raises an error as usual. + +- Constructors and initialization + - During construction, direct bases are initialized left‑to‑right in the declaration order. Each ancestor is initialized at most once (diamond‑safe de‑duplication). + - Arguments in the header are evaluated in the instance scope and passed to the corresponding direct base constructor. + - The most‑derived class’s constructor runs after the bases. + +- Visibility + - `private`: accessible only inside the declaring class body; not visible in subclasses and cannot be accessed via `this@Type` or casts. + - `protected`: accessible in the declaring class and in any of its transitive subclasses (including MI), but not from unrelated contexts; qualification/casts do not bypass it. + +- Diagnostics + - When a member/field is not found, error messages include the receiver class name and the considered linearization order, with suggestions to disambiguate using `this@Type` or casts if appropriate. + - Qualifying with a non‑ancestor in `this@Type` reports a clear error mentioning the receiver lineage. + - `as`/`as?` cast errors mention the actual and target types. + +Compatibility notes: + +- Existing single‑inheritance code continues to work unchanged; its resolution order reduces to the single base. +- If your previous code accidentally relied on non‑deterministic parent set iteration, it may change behavior — the new deterministic order is a correctness fix. + +### Migration note (declaration‑order → C3) + +Earlier drafts and docs described a declaration‑order depth‑first linearization. Lyng now uses C3 MRO for member lookup and disambiguation. Most code should continue to work unchanged, but in rare edge cases involving diamonds or complex multiple inheritance, the chosen base for an ambiguous member may change to reflect C3. If needed, disambiguate explicitly using `this@Type.member(...)` inside class bodies or casts `(expr as Type).member(...)` from outside. + ## fields and visibility It is possible to add non-constructor fields: @@ -130,6 +223,25 @@ Private fields are visible only _inside the class instance_: void >>> void +### Protected members + +Protected members are available to the declaring class and all of its transitive subclasses (including via MI), but not from unrelated contexts: + +``` +class A() { + protected fun ping() { "pong" } +} +class B() : A() { + fun call() { this@A.ping() } +} + +val b = B() +assertEquals("pong", b.call()) + +// Unrelated access is forbidden, even via cast +assertThrows { (b as A).ping() } +``` + It is possible to provide private constructor parameters so they can be set at construction but not available outside the class: diff --git a/docs/tutorial.md b/docs/tutorial.md index eca8bb6..2fb9cf5 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1,27 +1,3 @@ ---- -gitea: none -include_toc: true ---- - -# Lyng tutorial - -Lyng is a very simple language, where we take only most important and popular features from -other scripts and languages. In particular, we adopt _principle of minimal confusion_[^1]. -In other word, the code usually works as expected when you see it. So, nothing unusual. - -__Other documents to read__ maybe after this one: - -- [Advanced topics](advanced_topics.md), [declaring arguments](declaring_arguments.md) -- [OOP notes](OOP.md), [exception handling](exceptions_handling.md) -- [math in Lyng](math.md) -- [time](time.md) and [parallelism](parallelism.md) -- [parallelism] - multithreaded code, coroutines, etc. -- Some class - references: [List], [Set], [Map], [Real], [Range], [Iterable], [Iterator], [time manipulation](time.md), [Array], [RingBuffer], [Buffer]. -- Some samples: [combinatorics](samples/combinatorics.lyng.md), national vars and - loops: [сумма ряда](samples/сумма_ряда.lyng.md). More at [samples folder](samples) - -# Expressions Everything is an expression in Lyng. Even an empty block: @@ -1426,3 +1402,66 @@ Lambda avoid unnecessary execution if assertion is not failed. for example: [Array]: Array.md [Regex]: Regex.md + +## Multiple Inheritance (quick start) + +Lyng supports multiple inheritance (MI) with simple, predictable rules. For a full reference see OOP notes, this is a quick, copy‑paste friendly overview. + +Declare a class with multiple bases and pass constructor arguments to each base in the header: + +``` +class Foo(val a) { + var tag = "F" + fun runA() { "ResultA:" + a } + fun common() { "CommonA" } + private fun privateInFoo() {} + protected fun protectedInFoo() {} +} + +class Bar(val b) { + var tag = "B" + fun runB() { "ResultB:" + b } + fun common() { "CommonB" } +} + +class FooBar(a, b) : Foo(a), Bar(b) { + // Inside class bodies you can qualify with this@Type + fun fromFoo() { this@Foo.common() } + fun fromBar() { this@Bar.common() } +} + +val fb = FooBar(1, 2) +assertEquals("ResultA:1", fb.runA()) +assertEquals("ResultB:2", fb.runB()) + +// Unqualified ambiguous member uses the first base (leftmost) +assertEquals("CommonA", fb.common()) + +// Disambiguate with casts +assertEquals("CommonB", (fb as Bar).common()) +assertEquals("CommonA", (fb as Foo).common()) + +// Field collisions: unqualified read/write uses the first in order +assertEquals("F", fb.tag) +fb.tag = "X" +assertEquals("X", fb.tag) // Foo.tag updated +assertEquals("X", (fb as Foo).tag) // qualified read: Foo.tag +assertEquals("B", (fb as Bar).tag) // qualified read: Bar.tag + +// Qualified write targets the chosen base storage +(fb as Bar).tag = "Y" +assertEquals("X", (fb as Foo).tag) +assertEquals("Y", (fb as Bar).tag) + +// Optional casts with safe call +class Buzz : Bar(3) +val buzz = Buzz() +assertEquals("ResultB:3", buzz.runB()) +assertEquals("ResultB:3", (buzz as? Bar)?.runB()) +assertEquals(null, (buzz as? Foo)?.runA()) +``` + +Notes: +- Resolution order uses C3 MRO (active): deterministic, monotonic order suitable for diamonds and complex hierarchies. Example: for `class D() : B(), C()` where both `B()` and `C()` derive from `A()`, the C3 order is `D → B → C → A`. The first visible match wins. +- `private` is visible only inside the declaring class; `protected` is visible from the declaring class and any of its transitive subclasses. Qualification (`this@Type`) or casts do not bypass visibility. +- Safe‑call `?.` works with `as?` for optional dispatch. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClosureScope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClosureScope.kt index 041ea9c..46d466c 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClosureScope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClosureScope.kt @@ -29,17 +29,23 @@ class ClosureScope(val callScope: Scope, val closureScope: Scope) : // we captured, not to the caller's `this` (e.g., FlowBuilder). Scope(callScope, callScope.args, thisObj = closureScope.thisObj) { + init { + // Preserve the lexical class context from where the lambda was defined (closure), + // so that visibility checks (private/protected) inside lambdas executed within other + // methods (e.g., Mutex.withLock) still see the original declaring class context. + this.currentClassCtx = closureScope.currentClassCtx + } + override fun get(name: String): ObjRecord? { // Priority: - // 1) Arguments from the caller scope (if present in this frame) + // 1) Locals and arguments declared in this lambda frame (including values defined before suspension) // 2) Instance/class members of the captured receiver (`closureScope.thisObj`), e.g., fields like `coll`, `factor` // 3) Symbols from the captured closure scope (its locals and parents) // 4) Instance members of the caller's `this` (e.g., FlowBuilder.emit) // 5) Fallback to the standard chain (this frame -> parent (callScope) -> class members) - // note using super, not callScope, as arguments are assigned by the constructor - // and are not yet exposed via callScope.get at this point: - super.objects[name]?.let { if (it.type.isArgument) return it } + // First, prefer locals/arguments bound in this frame + super.objects[name]?.let { return it } // Prefer instance fields/methods declared on the captured receiver: // First, resolve real instance fields stored in the instance scope (constructor vars like `coll`, `factor`) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 9cc7bd0..6fc1a92 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -479,6 +479,8 @@ class Compiler( val callStatement = statement { // and the source closure of the lambda which might have other thisObj. val context = this.applyClosure(closure!!) + // Execute lambda body in a closure-aware context. Blocks inside the lambda + // will create child scopes as usual, so re-declarations inside loops work. if (argsDeclaration == null) { // no args: automatic var 'it' val l = args.list @@ -700,6 +702,37 @@ class Compiler( return args to lastBlockArgument } + /** + * Parse arguments inside parentheses without consuming any optional trailing block after the RPAREN. + * Useful in contexts where a following '{' has different meaning (e.g., class bodies after base lists). + */ + private suspend fun parseArgsNoTailBlock(): List { + val args = mutableListOf() + do { + val t = cc.next() + when (t.type) { + Token.Type.NEWLINE, + Token.Type.RPAREN, Token.Type.COMMA -> { + } + + Token.Type.ELLIPSIS -> { + parseStatement()?.let { args += ParsedArgument(it, t.pos, isSplat = true) } + ?: throw ScriptError(t.pos, "Expecting arguments list") + } + + else -> { + cc.previous() + parseExpression()?.let { args += ParsedArgument(it, t.pos) } + ?: throw ScriptError(t.pos, "Expecting arguments list") + if (cc.current().type == Token.Type.COLON) + parseTypeDeclaration() + } + } + } while (t.type != Token.Type.RPAREN) + // Do NOT peek for a trailing block; leave it to the outer parser + return args + } + private suspend fun parseFunctionCall( left: ObjRef, @@ -750,7 +783,18 @@ class Compiler( } Token.Type.ID -> { - when (t.value) { + // Special case: qualified this -> this@Type + if (t.value == "this") { + val pos = cc.savePos() + val next = cc.next() + if (next.pos.line == t.pos.line && next.type == Token.Type.ATLABEL) { + QualifiedThisRef(next.value, t.pos) + } else { + cc.restorePos(pos) + // plain this + LocalVarRef("this", t.pos) + } + } else when (t.value) { "void" -> ConstRef(ObjVoid.asReadonly) "null" -> ConstRef(ObjNull.asReadonly) "true" -> ConstRef(ObjTrue.asReadonly) @@ -818,6 +862,40 @@ class Compiler( private suspend fun parseKeywordStatement(id: Token): Statement? = when (id.value) { "val" -> parseVarDeclaration(false, Visibility.Public) "var" -> parseVarDeclaration(true, Visibility.Public) + // Ensure function declarations are recognized in all contexts (including class bodies) + "fun" -> parseFunctionDeclaration(isOpen = false, isExtern = false, isStatic = false) + "fn" -> parseFunctionDeclaration(isOpen = false, isExtern = false, isStatic = false) + // Visibility modifiers for declarations: private/protected val/var/fun/fn + "private" -> { + var k = cc.requireToken(Token.Type.ID, "declaration expected after 'private'") + var isStatic = false + if (k.value == "static") { + isStatic = true + k = cc.requireToken(Token.Type.ID, "declaration expected after 'private static'") + } + when (k.value) { + "val" -> parseVarDeclaration(false, Visibility.Private, isStatic = isStatic) + "var" -> parseVarDeclaration(true, Visibility.Private, isStatic = isStatic) + "fun" -> parseFunctionDeclaration(visibility = Visibility.Private, isOpen = false, isExtern = false, isStatic = isStatic) + "fn" -> parseFunctionDeclaration(visibility = Visibility.Private, isOpen = false, isExtern = false, isStatic = isStatic) + else -> k.raiseSyntax("unsupported private declaration kind: ${k.value}") + } + } + "protected" -> { + var k = cc.requireToken(Token.Type.ID, "declaration expected after 'protected'") + var isStatic = false + if (k.value == "static") { + isStatic = true + k = cc.requireToken(Token.Type.ID, "declaration expected after 'protected static'") + } + when (k.value) { + "val" -> parseVarDeclaration(false, Visibility.Protected, isStatic = isStatic) + "var" -> parseVarDeclaration(true, Visibility.Protected, isStatic = isStatic) + "fun" -> parseFunctionDeclaration(visibility = Visibility.Protected, isOpen = false, isExtern = false, isStatic = isStatic) + "fn" -> parseFunctionDeclaration(visibility = Visibility.Protected, isOpen = false, isExtern = false, isStatic = isStatic) + else -> k.raiseSyntax("unsupported protected declaration kind: ${k.value}") + } + } "while" -> parseWhileStatement() "do" -> parseDoWhileStatement() "for" -> parseForStatement() @@ -1162,19 +1240,40 @@ class Compiler( "Bad class declaration: expected ')' at the end of the primary constructor" ) + // Optional base list: ":" Base ("," Base)* where Base := ID ( "(" args? ")" )? + data class BaseSpec(val name: String, val args: List?) + val baseSpecs = mutableListOf() + if (cc.skipTokenOfType(Token.Type.COLON, isOptional = true)) { + do { + val baseId = cc.requireToken(Token.Type.ID, "base class name expected") + var argsList: List? = null + // Optional constructor args of the base — parse and ignore for now (MVP), just to consume tokens + if (cc.skipTokenOfType(Token.Type.LPAREN, isOptional = true)) { + // Parse args without consuming any following block so that a class body can follow safely + argsList = parseArgsNoTailBlock() + } + baseSpecs += BaseSpec(baseId.value, argsList) + } while (cc.skipTokenOfType(Token.Type.COMMA, isOptional = true)) + } + cc.skipTokenOfType(Token.Type.NEWLINE, isOptional = true) - val t = cc.next() pushInitScope() - val bodyInit: Statement? = if (t.type == Token.Type.LBRACE) { - // parse body - parseScript().also { - cc.skipTokens(Token.Type.RBRACE) + // Robust body detection: peek next non-whitespace token; if it's '{', consume and parse the body + val bodyInit: Statement? = run { + val saved = cc.savePos() + val next = cc.nextNonWhitespace() + if (next.type == Token.Type.LBRACE) { + // parse body + parseScript().also { + cc.skipTokens(Token.Type.RBRACE) + } + } else { + // restore if no body starts here + cc.restorePos(saved) + null } - } else { - cc.previous() - null } val initScope = popInitScope() @@ -1191,31 +1290,60 @@ class Compiler( val constructorCode = statement { // constructor code is registered with class instance and is called over // new `thisObj` already set by class to ObjInstance.instanceContext - thisObj as ObjInstance - - // the context now is a "class creation context", we must use its args to initialize - // fields. Note that 'this' is already set by class - constructorArgsDeclaration?.assignToContext(this) - bodyInit?.execute(this) - - thisObj + val instance = thisObj as ObjInstance + // Constructor parameters have been assigned to instance scope by ObjClass.callOn before + // invoking parent/child constructors. + // IMPORTANT: do not execute class body here; class body was executed once in the class scope + // to register methods and prepare initializers. Instance constructor should be empty unless + // we later add explicit constructor body syntax. + instance } - // inheritance must alter this code: - val newClass = ObjInstanceClass(className).apply { - instanceConstructor = constructorCode - constructorMeta = constructorArgsDeclaration - } - statement { // the main statement should create custom ObjClass instance with field // accessors, constructor registration, etc. + // Resolve parent classes by name at execution time + val parentClasses = baseSpecs.map { baseSpec -> + val rec = this[baseSpec.name] ?: throw ScriptError(nameToken.pos, "unknown base class: ${baseSpec.name}") + (rec.value as? ObjClass) ?: throw ScriptError(nameToken.pos, "${baseSpec.name} is not a class") + } + + val newClass = ObjInstanceClass(className, *parentClasses.toTypedArray()).also { + it.instanceConstructor = constructorCode + it.constructorMeta = constructorArgsDeclaration + // Attach per-parent constructor args (thunks) if provided + for (i in parentClasses.indices) { + val argsList = baseSpecs[i].args + if (argsList != null) it.directParentArgs[parentClasses[i]] = argsList + } + } + addItem(className, false, newClass) + // Prepare class scope for class-scope members (static) and future registrations + val classScope = createChildScope(newThisObj = newClass) + // Set lexical class context for visibility tagging inside class body + classScope.currentClassCtx = newClass + newClass.classScope = classScope + // Execute class body once in class scope to register instance methods and prepare instance field initializers + bodyInit?.execute(classScope) if (initScope.isNotEmpty()) { - val classScope = createChildScope(newThisObj = newClass) - newClass.classScope = classScope for (s in initScope) s.execute(classScope) } + // Fallback: ensure any functions declared in class scope are also present as instance methods + // (defensive in case some paths skipped cls.addFn during parsing/execution ordering) + for ((k, rec) in classScope.objects) { + val v = rec.value + if (v is Statement) { + if (newClass.members[k] == null) { + newClass.addFn(k, isOpen = true) { + (thisObj as? ObjInstance)?.let { i -> + v.execute(ClosureScope(this, i.instanceScope)) + } ?: v.execute(thisObj.autoInstanceScope(this)) + } + } + } + } + // Debug summary: list registered instance methods and class-scope functions for this class newClass } } @@ -1686,6 +1814,7 @@ class Compiler( } fnStatements.execute(context) } + val enclosingCtx = parentContext val fnCreateStatement = statement(start) { context -> // we added fn in the context. now we must save closure // for the function, unless we're in the class scope: @@ -1699,7 +1828,7 @@ class Compiler( // class extension method val type = context[typeName]?.value ?: context.raiseSymbolNotFound("class $typeName not found") if (type !is ObjClass) context.raiseClassCastError("$typeName is not the class instance") - type.addFn(name, isOpen = true) { + type.addFn(name, isOpen = true, visibility = visibility) { // ObjInstance has a fixed instance scope, so we need to build a closure (thisObj as? ObjInstance)?.let { i -> annotatedFnBody.execute(ClosureScope(this, i.instanceScope)) @@ -1709,7 +1838,31 @@ class Compiler( } } // regular function/method - ?: context.addItem(name, false, annotatedFnBody, visibility) + ?: run { + val th = context.thisObj + if (!isStatic && th is ObjClass) { + // Instance method declared inside a class body: register on the class + val cls: ObjClass = th + cls.addFn(name, isOpen = true, visibility = visibility) { + // Execute with the instance as receiver; set caller lexical class for visibility + val savedCtx = this.currentClassCtx + this.currentClassCtx = cls + try { + (thisObj as? ObjInstance)?.let { i -> + annotatedFnBody.execute(ClosureScope(this, i.instanceScope)) + } ?: annotatedFnBody.execute(thisObj.autoInstanceScope(this)) + } finally { + this.currentClassCtx = savedCtx + } + } + // also expose the symbol in the class scope for possible references + context.addItem(name, false, annotatedFnBody, visibility) + annotatedFnBody + } else { + // top-level or nested function + context.addItem(name, false, annotatedFnBody, visibility) + } + } // as the function can be called from anywhere, we have // saved the proper context in the closure annotatedFnBody @@ -1791,9 +1944,18 @@ class Compiler( return NopStatement } + // Determine declaring class (if inside class body) at compile time, capture it in the closure + val declaringClassNameCaptured = (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.name + return statement(nameToken.pos) { context -> - if (context.containsLocal(name)) - throw ScriptError(nameToken.pos, "Variable $name is already defined") + // In true class bodies (not inside a function), store fields under a class-qualified key to support MI collisions + // Do NOT infer declaring class from runtime thisObj here; only the compile-time captured + // ClassBody qualifies for class-field storage. Otherwise, this is a plain local. + val declaringClassName = declaringClassNameCaptured + if (declaringClassName == null) { + if (context.containsLocal(name)) + throw ScriptError(nameToken.pos, "Variable $name is already defined") + } // Register the local name so subsequent identifiers can be emitted as fast locals if (!isStatic) declareLocalName(name) @@ -1818,11 +1980,32 @@ class Compiler( // context.addItem(name, isMutable, it, visibility, recordType = ObjRecord.Type.Field) // } } else { - // init value could be a val; when we initialize by-value type var with it, we need to - // create a separate copy: - val initValue = initialExpression?.execute(context)?.byValueCopy() ?: ObjNull - context.addItem(name, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) - initValue + if (declaringClassName != null && !isStatic) { + val storageName = "$declaringClassName::$name" + // If we are in class scope now (defining instance field), defer initialization to instance time + val isClassScope = context.thisObj is ObjClass && (context.thisObj !is ObjInstance) + if (isClassScope) { + val cls = context.thisObj as ObjClass + // Defer: at instance construction, evaluate initializer in instance scope and store under mangled name + val initStmt = statement(nameToken.pos) { scp -> + val initValue = initialExpression?.execute(scp)?.byValueCopy() ?: ObjNull + scp.addOrUpdateItem(storageName, initValue, visibility, recordType = ObjRecord.Type.Field) + ObjVoid + } + cls.instanceInitializers += initStmt + ObjVoid + } else { + // We are in instance scope already: perform initialization immediately + val initValue = initialExpression?.execute(context)?.byValueCopy() ?: ObjNull + context.addOrUpdateItem(storageName, initValue, visibility, recordType = ObjRecord.Type.Field) + initValue + } + } else { + // Not in class body: regular local/var declaration + val initValue = initialExpression?.execute(context)?.byValueCopy() ?: ObjNull + context.addItem(name, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) + initValue + } } } } @@ -2027,6 +2210,13 @@ class Compiler( Operator(Token.Type.NOTIS, lastPriority) { _, a, b -> BinaryOpRef(BinOp.NOTIS, a, b) }, + // casts: as / as? + Operator(Token.Type.AS, lastPriority) { pos, a, b -> + CastRef(a, b, false, pos) + }, + Operator(Token.Type.ASNULL, lastPriority) { pos, a, b -> + CastRef(a, b, true, pos) + }, Operator(Token.Type.ELVIS, ++lastPriority, 2) { _, a, b -> ElvisRef(a, b) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt index 1c18bfc..a52de96 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt @@ -342,6 +342,11 @@ private class Parser(fromPos: Pos) { when (text) { "in" -> Token("in", from, Token.Type.IN) "is" -> Token("is", from, Token.Type.IS) + "as" -> { + // support both `as` and tight `as?` without spaces + if (currentChar == '?') { pos.advance(); Token("as?", from, Token.Type.ASNULL) } + else Token("as", from, Token.Type.AS) + } else -> Token(text, from, Token.Type.ID) } } else diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/PerfProfiles.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/PerfProfiles.kt new file mode 100644 index 0000000..19276a9 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/PerfProfiles.kt @@ -0,0 +1,221 @@ +/* + * Copyright 2025 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.PerfProfiles.restore + + +/** + * Helper to quickly apply groups of [PerfFlags] for different workload profiles and restore them back. + * JVM-first defaults; safe to use on all targets. Applying a preset returns a [Snapshot] that can be + * passed to [restore] to revert the change. + */ +object PerfProfiles { + data class Snapshot( + val LOCAL_SLOT_PIC: Boolean, + val EMIT_FAST_LOCAL_REFS: Boolean, + + val ARG_BUILDER: Boolean, + val ARG_SMALL_ARITY_12: Boolean, + val SKIP_ARGS_ON_NULL_RECEIVER: Boolean, + val SCOPE_POOL: Boolean, + + val FIELD_PIC: Boolean, + val METHOD_PIC: Boolean, + val FIELD_PIC_SIZE_4: Boolean, + val METHOD_PIC_SIZE_4: Boolean, + val PIC_ADAPTIVE_2_TO_4: Boolean, + val PIC_ADAPTIVE_METHODS_ONLY: Boolean, + val PIC_ADAPTIVE_HEURISTIC: Boolean, + + val INDEX_PIC: Boolean, + val INDEX_PIC_SIZE_4: Boolean, + + val PIC_DEBUG_COUNTERS: Boolean, + + val PRIMITIVE_FASTOPS: Boolean, + val RVAL_FASTPATH: Boolean, + + val REGEX_CACHE: Boolean, + val RANGE_FAST_ITER: Boolean, + ) + + fun snapshot(): Snapshot = Snapshot( + LOCAL_SLOT_PIC = PerfFlags.LOCAL_SLOT_PIC, + EMIT_FAST_LOCAL_REFS = PerfFlags.EMIT_FAST_LOCAL_REFS, + + ARG_BUILDER = PerfFlags.ARG_BUILDER, + ARG_SMALL_ARITY_12 = PerfFlags.ARG_SMALL_ARITY_12, + SKIP_ARGS_ON_NULL_RECEIVER = PerfFlags.SKIP_ARGS_ON_NULL_RECEIVER, + SCOPE_POOL = PerfFlags.SCOPE_POOL, + + FIELD_PIC = PerfFlags.FIELD_PIC, + METHOD_PIC = PerfFlags.METHOD_PIC, + FIELD_PIC_SIZE_4 = PerfFlags.FIELD_PIC_SIZE_4, + METHOD_PIC_SIZE_4 = PerfFlags.METHOD_PIC_SIZE_4, + PIC_ADAPTIVE_2_TO_4 = PerfFlags.PIC_ADAPTIVE_2_TO_4, + PIC_ADAPTIVE_METHODS_ONLY = PerfFlags.PIC_ADAPTIVE_METHODS_ONLY, + PIC_ADAPTIVE_HEURISTIC = PerfFlags.PIC_ADAPTIVE_HEURISTIC, + + INDEX_PIC = PerfFlags.INDEX_PIC, + INDEX_PIC_SIZE_4 = PerfFlags.INDEX_PIC_SIZE_4, + + PIC_DEBUG_COUNTERS = PerfFlags.PIC_DEBUG_COUNTERS, + + PRIMITIVE_FASTOPS = PerfFlags.PRIMITIVE_FASTOPS, + RVAL_FASTPATH = PerfFlags.RVAL_FASTPATH, + + REGEX_CACHE = PerfFlags.REGEX_CACHE, + RANGE_FAST_ITER = PerfFlags.RANGE_FAST_ITER, + ) + + fun restore(s: Snapshot) { + PerfFlags.LOCAL_SLOT_PIC = s.LOCAL_SLOT_PIC + PerfFlags.EMIT_FAST_LOCAL_REFS = s.EMIT_FAST_LOCAL_REFS + + PerfFlags.ARG_BUILDER = s.ARG_BUILDER + PerfFlags.ARG_SMALL_ARITY_12 = s.ARG_SMALL_ARITY_12 + PerfFlags.SKIP_ARGS_ON_NULL_RECEIVER = s.SKIP_ARGS_ON_NULL_RECEIVER + PerfFlags.SCOPE_POOL = s.SCOPE_POOL + + PerfFlags.FIELD_PIC = s.FIELD_PIC + PerfFlags.METHOD_PIC = s.METHOD_PIC + PerfFlags.FIELD_PIC_SIZE_4 = s.FIELD_PIC_SIZE_4 + PerfFlags.METHOD_PIC_SIZE_4 = s.METHOD_PIC_SIZE_4 + PerfFlags.PIC_ADAPTIVE_2_TO_4 = s.PIC_ADAPTIVE_2_TO_4 + PerfFlags.PIC_ADAPTIVE_METHODS_ONLY = s.PIC_ADAPTIVE_METHODS_ONLY + PerfFlags.PIC_ADAPTIVE_HEURISTIC = s.PIC_ADAPTIVE_HEURISTIC + + PerfFlags.INDEX_PIC = s.INDEX_PIC + PerfFlags.INDEX_PIC_SIZE_4 = s.INDEX_PIC_SIZE_4 + + PerfFlags.PIC_DEBUG_COUNTERS = s.PIC_DEBUG_COUNTERS + + PerfFlags.PRIMITIVE_FASTOPS = s.PRIMITIVE_FASTOPS + PerfFlags.RVAL_FASTPATH = s.RVAL_FASTPATH + + PerfFlags.REGEX_CACHE = s.REGEX_CACHE + PerfFlags.RANGE_FAST_ITER = s.RANGE_FAST_ITER + } + + enum class Preset { BASELINE, BENCH, BOOKS } + + /** Apply a preset and return a [Snapshot] of the previous state to allow restoring later. */ + fun apply(preset: Preset): Snapshot { + val saved = snapshot() + when (preset) { + Preset.BASELINE -> applyBaseline() + Preset.BENCH -> applyBench() + Preset.BOOKS -> applyBooks() + } + return saved + } + + private fun applyBaseline() { + // Restore platform defaults. Note: INDEX_PIC follows FIELD_PIC parity by convention. + PerfFlags.LOCAL_SLOT_PIC = PerfDefaults.LOCAL_SLOT_PIC + PerfFlags.EMIT_FAST_LOCAL_REFS = PerfDefaults.EMIT_FAST_LOCAL_REFS + + PerfFlags.ARG_BUILDER = PerfDefaults.ARG_BUILDER + PerfFlags.ARG_SMALL_ARITY_12 = PerfDefaults.ARG_SMALL_ARITY_12 + PerfFlags.SKIP_ARGS_ON_NULL_RECEIVER = PerfDefaults.SKIP_ARGS_ON_NULL_RECEIVER + PerfFlags.SCOPE_POOL = PerfDefaults.SCOPE_POOL + + PerfFlags.FIELD_PIC = PerfDefaults.FIELD_PIC + PerfFlags.METHOD_PIC = PerfDefaults.METHOD_PIC + PerfFlags.FIELD_PIC_SIZE_4 = PerfDefaults.FIELD_PIC_SIZE_4 + PerfFlags.METHOD_PIC_SIZE_4 = PerfDefaults.METHOD_PIC_SIZE_4 + PerfFlags.PIC_ADAPTIVE_2_TO_4 = PerfDefaults.PIC_ADAPTIVE_2_TO_4 + PerfFlags.PIC_ADAPTIVE_METHODS_ONLY = PerfDefaults.PIC_ADAPTIVE_METHODS_ONLY + PerfFlags.PIC_ADAPTIVE_HEURISTIC = PerfDefaults.PIC_ADAPTIVE_HEURISTIC + + PerfFlags.INDEX_PIC = PerfFlags.FIELD_PIC + PerfFlags.INDEX_PIC_SIZE_4 = PerfDefaults.INDEX_PIC_SIZE_4 + + PerfFlags.PIC_DEBUG_COUNTERS = PerfDefaults.PIC_DEBUG_COUNTERS + + PerfFlags.PRIMITIVE_FASTOPS = PerfDefaults.PRIMITIVE_FASTOPS + PerfFlags.RVAL_FASTPATH = PerfDefaults.RVAL_FASTPATH + + PerfFlags.REGEX_CACHE = PerfDefaults.REGEX_CACHE + PerfFlags.RANGE_FAST_ITER = PerfDefaults.RANGE_FAST_ITER + } + + private fun applyBench() { + // Expression-heavy micro-bench focus (JVM-first assumptions) + PerfFlags.LOCAL_SLOT_PIC = true + PerfFlags.EMIT_FAST_LOCAL_REFS = true + + PerfFlags.ARG_BUILDER = true + PerfFlags.ARG_SMALL_ARITY_12 = false + PerfFlags.SKIP_ARGS_ON_NULL_RECEIVER = true + PerfFlags.SCOPE_POOL = true + + PerfFlags.FIELD_PIC = true + PerfFlags.METHOD_PIC = true + PerfFlags.FIELD_PIC_SIZE_4 = false + PerfFlags.METHOD_PIC_SIZE_4 = false + PerfFlags.PIC_ADAPTIVE_2_TO_4 = false + PerfFlags.PIC_ADAPTIVE_METHODS_ONLY = false + PerfFlags.PIC_ADAPTIVE_HEURISTIC = false + + PerfFlags.INDEX_PIC = true + PerfFlags.INDEX_PIC_SIZE_4 = true + + PerfFlags.PIC_DEBUG_COUNTERS = false + + PerfFlags.PRIMITIVE_FASTOPS = true + PerfFlags.RVAL_FASTPATH = true + + // Keep regex cache/platform setting; enable on JVM typically + PerfFlags.REGEX_CACHE = PerfDefaults.REGEX_CACHE + PerfFlags.RANGE_FAST_ITER = false + } + + private fun applyBooks() { + // Documentation/book workload focus based on profiling observations. + // Favor simpler paths when hot expression paths are not dominant. + PerfFlags.LOCAL_SLOT_PIC = PerfDefaults.LOCAL_SLOT_PIC + PerfFlags.EMIT_FAST_LOCAL_REFS = PerfDefaults.EMIT_FAST_LOCAL_REFS + + PerfFlags.ARG_BUILDER = false + PerfFlags.ARG_SMALL_ARITY_12 = false + PerfFlags.SKIP_ARGS_ON_NULL_RECEIVER = true + PerfFlags.SCOPE_POOL = false + + PerfFlags.FIELD_PIC = false + PerfFlags.METHOD_PIC = false + PerfFlags.FIELD_PIC_SIZE_4 = false + PerfFlags.METHOD_PIC_SIZE_4 = false + PerfFlags.PIC_ADAPTIVE_2_TO_4 = false + PerfFlags.PIC_ADAPTIVE_METHODS_ONLY = false + PerfFlags.PIC_ADAPTIVE_HEURISTIC = false + + PerfFlags.INDEX_PIC = false + PerfFlags.INDEX_PIC_SIZE_4 = false + + PerfFlags.PIC_DEBUG_COUNTERS = false + + PerfFlags.PRIMITIVE_FASTOPS = false + PerfFlags.RVAL_FASTPATH = false + + // Keep regex cache/platform default; ON on JVM usually helps string APIs in books + PerfFlags.REGEX_CACHE = PerfDefaults.REGEX_CACHE + PerfFlags.RANGE_FAST_ITER = false + } +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt index 04d26f1..ec49432 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt @@ -46,6 +46,8 @@ open class Scope( var thisObj: Obj = ObjVoid, var skipScopeCreation: Boolean = false, ) { + /** Lexical class context for visibility checks (propagates from parent). */ + var currentClassCtx: net.sergeych.lyng.obj.ObjClass? = parent?.currentClassCtx // Unique id per scope frame for PICs; regenerated on each borrow from the pool. var frameId: Long = nextFrameId() @@ -53,6 +55,12 @@ open class Scope( // Enabled by default for child scopes; module/class scopes can ignore it. private val slots: MutableList = mutableListOf() private val nameToSlot: MutableMap = mutableMapOf() + /** + * Auxiliary per-frame map of local bindings (locals declared in this frame). + * This helps resolving locals across suspension when slot ownership isn't + * directly discoverable from the current frame. + */ + internal val localBindings: MutableMap = mutableMapOf() /** * Hint internal collections to reduce reallocations for upcoming parameter/local assignments. @@ -162,11 +170,15 @@ open class Scope( open operator fun get(name: String): ObjRecord? = if (name == "this") thisObj.asReadonly else { + // Prefer direct locals/bindings declared in this frame (objects[name] + // Then, check known local bindings in this frame (helps after suspension) + ?: localBindings[name] + // Walk up ancestry ?: parent?.get(name) - ?: thisObj.objClass - .getInstanceMemberOrNull(name) - ) + // Finally, fallback to class members on thisObj + ?: thisObj.objClass.getInstanceMemberOrNull(name) + ) } // Slot fast-path API @@ -199,6 +211,7 @@ open class Scope( objects.clear() slots.clear() nameToSlot.clear() + localBindings.clear() // Pre-size local slots for upcoming parameter assignment where possible reserveLocalCapacity(args.list.size + 4) } @@ -258,6 +271,13 @@ open class Scope( if( !it.isMutable ) raiseIllegalAssignment("symbol is readonly: $name") it.value = value + // keep local binding index consistent within the frame + localBindings[name] = it + // If we are a ClosureScope, mirror binding into the caller frame to keep it discoverable + // across suspension when resumed on the call frame + if (this is ClosureScope) { + callScope.localBindings[name] = it + } it } ?: addItem(name, true, value, visibility, recordType) @@ -268,8 +288,23 @@ open class Scope( visibility: Visibility = Visibility.Public, recordType: ObjRecord.Type = ObjRecord.Type.Other ): ObjRecord { - val rec = ObjRecord(value, isMutable, visibility, type = recordType) + val rec = ObjRecord(value, isMutable, visibility, declaringClass = currentClassCtx, type = recordType) objects[name] = rec + // Index this binding within the current frame to help resolve locals across suspension + localBindings[name] = rec + // If we are a ClosureScope, mirror binding into the caller frame to keep it discoverable + // across suspension when resumed on the call frame + if (this is ClosureScope) { + callScope.localBindings[name] = rec + // Additionally, expose the binding in caller's objects and slot map so identifier + // resolution after suspension can still find it even if the active scope is a child + // of the callScope (e.g., due to internal withChildFrame usage). + // This keeps visibility within the method body but prevents leaking outside the caller frame. + callScope.objects[name] = rec + if (callScope.getSlotIndexOf(name) == null) { + callScope.allocateSlotFor(name, rec) + } + } // Map to a slot for fast local access (if not already mapped) if (getSlotIndexOf(name) == null) { allocateSlotFor(name, rec) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Token.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Token.kt index aee7e3b..a9c790a 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Token.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Token.kt @@ -43,6 +43,8 @@ data class Token(val value: String, val pos: Pos, val type: Type) { SHL, SHR, SINLGE_LINE_COMMENT, MULTILINE_COMMENT, LABEL, ATLABEL, // label@ at@label + // type-checking/casting + AS, ASNULL, //PUBLIC, PROTECTED, INTERNAL, EXPORT, OPEN, INLINE, OVERRIDE, ABSTRACT, SEALED, EXTERNAL, VAL, VAR, CONST, TYPE, FUN, CLASS, INTERFACE, ENUM, OBJECT, TRAIT, THIS, ELLIPSIS, DOTDOT, DOTDOTLT, NEWLINE, diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Visibility.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Visibility.kt index c493d9a..51bd637 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Visibility.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Visibility.kt @@ -22,4 +22,18 @@ enum class Visibility { val isPublic by lazy { this == Public } @Suppress("unused") val isProtected by lazy { this == Protected } +} + +/** MI-aware visibility check: whether [caller] can access a member declared in [decl] with [visibility]. */ +fun canAccessMember(visibility: Visibility, decl: net.sergeych.lyng.obj.ObjClass?, caller: net.sergeych.lyng.obj.ObjClass?): Boolean { + return when (visibility) { + Visibility.Public -> true + Visibility.Private -> (decl != null && caller === decl) + Visibility.Protected -> when { + decl == null -> false + caller == null -> false + caller === decl -> true + else -> (caller.allParentsSet.contains(decl)) + } + } } \ No newline at end of file diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt index c5a9901..951a620 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt @@ -80,13 +80,26 @@ open class Obj { args: Arguments = Arguments.EMPTY, onNotFoundResult: (() -> Obj?)? = null ): Obj { - return objClass.getInstanceMemberOrNull(name)?.value?.invoke( - scope, - this, - args - ) - ?: onNotFoundResult?.invoke() - ?: scope.raiseSymbolNotFound(name) + val rec = objClass.getInstanceMemberOrNull(name) + if (rec != null) { + val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx + if (!canAccessMember(rec.visibility, decl, caller)) + scope.raiseError(ObjAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})")) + // Propagate declaring class as current class context during method execution + val saved = scope.currentClassCtx + scope.currentClassCtx = decl + try { + return rec.value.invoke(scope, this, args) + } finally { + scope.currentClassCtx = saved + } + } + return onNotFoundResult?.invoke() + ?: scope.raiseError( + "no such member: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}. " + + "Tip: try this@Base.$name(...) or (obj as Base).$name(...) if ambiguous" + ) } open suspend fun getInstanceMethod( @@ -258,7 +271,13 @@ open class Obj { open suspend fun readField(scope: Scope, name: String): ObjRecord { // could be property or class field: - val obj = objClass.getInstanceMemberOrNull(name) ?: scope.raiseError("no such field: $name") + val obj = objClass.getInstanceMemberOrNull(name) ?: scope.raiseError( + "no such field: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}" + ) + val decl = obj.declaringClass ?: objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx + if (!canAccessMember(obj.visibility, decl, caller)) + scope.raiseError(ObjAccessException(scope, "can't access field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})")) return when (val value = obj.value) { is Statement -> { ObjRecord(value.execute(scope.createChildScope(scope.pos, newThisObj = this)), obj.isMutable) @@ -271,7 +290,13 @@ open class Obj { open suspend fun writeField(scope: Scope, name: String, newValue: Obj) { willMutate(scope) - val field = objClass.getInstanceMemberOrNull(name) ?: scope.raiseError("no such field: $name") + val field = objClass.getInstanceMemberOrNull(name) ?: scope.raiseError( + "no such field: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}" + ) + val decl = field.declaringClass ?: objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx + if (!canAccessMember(field.visibility, decl, caller)) + scope.raiseError(ObjAccessException(scope, "can't assign field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})")) if (field.isMutable) field.value = newValue else scope.raiseError("can't assign to read-only field: $name") } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt index 494a989..2c75ee0 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt @@ -40,6 +40,12 @@ open class ObjClass( var constructorMeta: ArgsDeclaration? = null var instanceConstructor: Statement? = null + /** + * Per-instance initializers collected from class body (for instance fields). These are executed + * during construction in the instance scope of the object, once per class in the hierarchy. + */ + val instanceInitializers: MutableList = mutableListOf() + /** * the scope for class methods, initialize class vars, etc. * @@ -50,10 +56,77 @@ open class ObjClass( */ var classScope: Scope? = null + /** Direct parents in declaration order (kept deterministic). */ + val directParents: List = parents.toList() + + /** Optional constructor argument specs for each direct parent (set by compiler for user classes). */ + open val directParentArgs: MutableMap> = mutableMapOf() + + /** + * All ancestors as a Set for fast `isInstanceOf` checks. Order is not guaranteed here and + * must not be used for resolution — use [parentsLinearized] instead. + */ val allParentsSet: Set = - parents.flatMap { - listOf(it) + it.allParentsSet - }.toMutableSet() + buildSet { + fun collect(c: ObjClass) { + if (add(c)) c.directParents.forEach { collect(it) } + } + directParents.forEach { collect(it) } + } + + // --- C3 Method Resolution Order (MRO) --- + private fun c3Merge(seqs: MutableList>): List { + val result = mutableListOf() + while (seqs.isNotEmpty()) { + // remove empty lists + seqs.removeAll { it.isEmpty() } + if (seqs.isEmpty()) break + var candidate: ObjClass? = null + outer@ for (seq in seqs) { + val head = seq.first() + // head must not appear in any other list's tail + var inTail = false + for (other in seqs) { + if (other === seq || other.size <= 1) continue + if (other.drop(1).contains(head)) { inTail = true; break } + } + if (!inTail) { candidate = head; break@outer } + } + val picked = candidate ?: throw ScriptError(Pos.builtIn, "C3 MRO failed: inconsistent hierarchy for $className") + result += picked + // remove picked from heads + for (seq in seqs) if (seq.isNotEmpty() && seq.first() === picked) seq.removeAt(0) + } + return result + } + + private fun c3Linearize(self: ObjClass, visited: MutableMap>): List { + visited[self]?.let { return it } + // Linearize parents first + val parentLinearizations = self.directParents.map { c3Linearize(it, visited) } + // Merge parent MROs with the direct parent list + val toMerge: MutableList> = mutableListOf() + parentLinearizations.forEach { toMerge += it.toMutableList() } + toMerge += self.directParents.toMutableList() + val merged = c3Merge(toMerge) + val mro = listOf(self) + merged + visited[self] = mro + return mro + } + + /** Full C3 MRO including this class at index 0. */ + val mro: List by lazy { c3Linearize(this, mutableMapOf()) } + + /** Parents in C3 order (no self). */ + val mroParents: List by lazy { mro.drop(1) } + + /** Render current linearization order for diagnostics (C3). */ + fun renderLinearization(includeSelf: Boolean = true): String { + val list = mutableListOf() + if (includeSelf) list += className + mroParents.forEach { list += it.className } + return list.joinToString(" → ") + } override val objClass: ObjClass by lazy { ObjClassType } @@ -73,9 +146,70 @@ open class ObjClass( // remains stable even when call frames are pooled and reused. val stableParent = scope.parent instance.instanceScope = Scope(stableParent, scope.args, scope.pos, instance) - if (instanceConstructor != null) { - instanceConstructor!!.execute(instance.instanceScope) + // Expose instance methods (and other callable members) directly in the instance scope for fast lookup + // This mirrors Obj.autoInstanceScope behavior for ad-hoc scopes and makes fb.method() resolution robust + // 1) members-defined methods + for ((k, v) in members) { + if (v.value is Statement) { + instance.instanceScope.objects[k] = v + } } + // 2) class-scope methods registered during class-body execution + classScope?.objects?.forEach { (k, rec) -> + if (rec.value is Statement) { + // if not already present, copy reference for dispatch + if (!instance.instanceScope.objects.containsKey(k)) { + instance.instanceScope.objects[k] = rec + } + } + } + // Constructor chaining MVP: initialize base classes left-to-right, then this class. + // Ensure each ancestor is initialized at most once (diamond-safe). + val visited = hashSetOf() + + suspend fun initClass(c: ObjClass, argsForThis: Arguments?, isRoot: Boolean = false) { + if (!visited.add(c)) return + // For the most-derived class, bind its constructor params BEFORE parents so base arg thunks can see them + if (isRoot) { + c.constructorMeta?.let { meta -> + val argsHere = argsForThis ?: Arguments.EMPTY + meta.assignToContext(instance.instanceScope, argsHere) + } + } + // Initialize direct parents first, in order + for (p in c.directParents) { + val raw = c.directParentArgs[p]?.toArguments(instance.instanceScope, false) + val limited = if (raw != null) { + val need = p.constructorMeta?.params?.size ?: 0 + if (need == 0) Arguments.EMPTY else Arguments(raw.list.take(need), tailBlockMode = false) + } else Arguments.EMPTY + initClass(p, limited) + } + // Execute per-instance initializers collected from class body for this class + if (c.instanceInitializers.isNotEmpty()) { + val savedCtx = instance.instanceScope.currentClassCtx + instance.instanceScope.currentClassCtx = c + try { + for (initStmt in c.instanceInitializers) { + initStmt.execute(instance.instanceScope) + } + } finally { + instance.instanceScope.currentClassCtx = savedCtx + } + } + // Then run this class' constructor, if any + c.instanceConstructor?.let { ctor -> + // Bind this class's constructor parameters into the instance scope now, right before ctor + c.constructorMeta?.let { meta -> + val argsHere = argsForThis ?: Arguments.EMPTY + meta.assignToContext(instance.instanceScope, argsHere) + } + val execScope = instance.instanceScope.createChildScope(args = argsForThis ?: Arguments.EMPTY, newThisObj = instance) + ctor.execute(execScope) + } + } + + initClass(this, instance.instanceScope.args, isRoot = true) return instance } @@ -91,10 +225,12 @@ open class ObjClass( visibility: Visibility = Visibility.Public, pos: Pos = Pos.builtIn ) { - val existing = members[name] ?: allParentsSet.firstNotNullOfOrNull { it.members[name] } - if (existing?.isMutable == false) - throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes") - members[name] = ObjRecord(initialValue, isMutable, visibility) + // Allow overriding ancestors: only prevent redefinition if THIS class already defines an immutable member + val existingInSelf = members[name] + if (existingInSelf != null && existingInSelf.isMutable == false) + throw ScriptError(pos, "$name is already defined in $objClass") + // Install/override in this class + members[name] = ObjRecord(initialValue, isMutable, visibility, declaringClass = this) // Structural change: bump layout version for PIC invalidation layoutVersion += 1 } @@ -120,8 +256,14 @@ open class ObjClass( layoutVersion += 1 } - fun addFn(name: String, isOpen: Boolean = false, code: suspend Scope.() -> Obj) { - createField(name, statement { code() }, isOpen) + fun addFn( + name: String, + isOpen: Boolean = false, + visibility: net.sergeych.lyng.Visibility = net.sergeych.lyng.Visibility.Public, + code: suspend Scope.() -> Obj + ) { + val stmt = statement { code() } + createField(name, stmt, isOpen, visibility) } fun addConst(name: String, value: Obj) = createField(name, value, isMutable = false) @@ -135,15 +277,46 @@ open class ObjClass( * Get instance member traversing the hierarchy if needed. Its meaning is different for different objects. */ fun getInstanceMemberOrNull(name: String): ObjRecord? { - members[name]?.let { return it } - allParentsSet.forEach { parent -> parent.getInstanceMemberOrNull(name)?.let { return it } } + // Unified traversal in strict C3 order: self, then each ancestor, checking members before classScope + for (cls in mro) { + cls.members[name]?.let { return it } + cls.classScope?.objects?.get(name)?.let { return it } + } + // Finally, allow root object fallback (rare; mostly built-ins like toString) return rootObjectType.members[name] } + /** Find the declaring class where a member with [name] is defined, starting from this class along MRO. */ + fun findDeclaringClassOf(name: String): ObjClass? { + if (members.containsKey(name)) return this + for (anc in mroParents) { + if (anc.members.containsKey(name)) return anc + } + return if (rootObjectType.members.containsKey(name)) rootObjectType else null + } + fun getInstanceMember(atPos: Pos, name: String): ObjRecord = getInstanceMemberOrNull(name) ?: throw ScriptError(atPos, "symbol doesn't exist: $name") + /** + * Resolve member starting from a specific ancestor class [start], not from this class. + * Searches [start] first, then traverses its linearized parents. + */ + fun getInstanceMemberFromAncestor(start: ObjClass, name: String): ObjRecord? { + val order = mro + val idx = order.indexOf(start) + if (idx < 0) return null + for (i in idx until order.size) { + val cls = order[i] + // Prefer true instance members on the class + cls.members[name]?.let { return it } + // Fallback to class-scope function registered during class-body execution + cls.classScope?.objects?.get(name)?.let { return it } + } + return rootObjectType.members[name] + } + override suspend fun readField(scope: Scope, name: String): ObjRecord { classScope?.objects?.get(name)?.let { if (it.visibility.isPublic) return it diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt index 90015e6..8bfe0d9 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt @@ -19,6 +19,7 @@ package net.sergeych.lyng.obj import net.sergeych.lyng.Arguments import net.sergeych.lyng.Scope +import net.sergeych.lyng.canAccessMember import net.sergeych.lynon.LynonDecoder import net.sergeych.lynon.LynonEncoder import net.sergeych.lynon.LynonType @@ -28,40 +29,124 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { internal lateinit var instanceScope: Scope override suspend fun readField(scope: Scope, name: String): ObjRecord { - return instanceScope[name]?.let { - if (it.visibility.isPublic) - it - else - scope.raiseError(ObjAccessException(scope, "can't access non-public field $name")) + // Direct (unmangled) lookup first + instanceScope[name]?.let { + val decl = it.declaringClass ?: objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx ?: instanceScope.currentClassCtx + val allowed = if (it.visibility == net.sergeych.lyng.Visibility.Private) (decl === objClass) else canAccessMember(it.visibility, decl, caller) + if (!allowed) + scope.raiseError(ObjAccessException(scope, "can't access field $name (declared in ${decl?.className ?: "?"})")) + return it } - ?: super.readField(scope, name) + // Try MI-mangled lookup along linearization (C3 MRO): ClassName::name + val cls = objClass + // self first, then parents + fun findMangled(): ObjRecord? { + // self + instanceScope.objects["${cls.className}::$name"]?.let { return it } + // ancestors in deterministic C3 order + for (p in cls.mroParents) { + instanceScope.objects["${p.className}::$name"]?.let { return it } + } + return null + } + findMangled()?.let { rec -> + val declName = rec.importedFrom?.packageName // unused; use mangled key instead + // derive declaring class by mangled prefix: try self then parents + val declaring = when { + instanceScope.objects.containsKey("${cls.className}::$name") -> cls + else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") } + } + val caller = scope.currentClassCtx ?: instanceScope.currentClassCtx + val allowed = if (rec.visibility == net.sergeych.lyng.Visibility.Private) (declaring === objClass) else canAccessMember(rec.visibility, declaring, caller) + if (!allowed) + scope.raiseError(ObjAccessException(scope, "can't access field $name (declared in ${declaring?.className ?: "?"})")) + return rec + } + // Fall back to methods/properties on class + return super.readField(scope, name) } override suspend fun writeField(scope: Scope, name: String, newValue: Obj) { + // Direct (unmangled) first instanceScope[name]?.let { f -> - if (!f.visibility.isPublic) - ObjIllegalAssignmentException(scope, "can't assign to non-public field $name") + val decl = f.declaringClass ?: objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx ?: instanceScope.currentClassCtx + val allowed = if (f.visibility == net.sergeych.lyng.Visibility.Private) (decl === objClass) else canAccessMember(f.visibility, decl, caller) + if (!allowed) + ObjIllegalAssignmentException(scope, "can't assign to field $name (declared in ${decl?.className ?: "?"})").raise() if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (f.value.assign(scope, newValue) == null) f.value = newValue - } ?: super.writeField(scope, name, newValue) + return + } + // Try MI-mangled resolution along linearization (C3 MRO) + val cls = objClass + fun findMangled(): ObjRecord? { + instanceScope.objects["${cls.className}::$name"]?.let { return it } + for (p in cls.mroParents) { + instanceScope.objects["${p.className}::$name"]?.let { return it } + } + return null + } + val rec = findMangled() + if (rec != null) { + val declaring = when { + instanceScope.objects.containsKey("${cls.className}::$name") -> cls + else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") } + } + val caller = scope.currentClassCtx ?: instanceScope.currentClassCtx + val allowed = if (rec.visibility == net.sergeych.lyng.Visibility.Private) (declaring === objClass) else canAccessMember(rec.visibility, declaring, caller) + if (!allowed) + ObjIllegalAssignmentException(scope, "can't assign to field $name (declared in ${declaring?.className ?: "?"})").raise() + if (!rec.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() + if (rec.value.assign(scope, newValue) == null) + rec.value = newValue + return + } + super.writeField(scope, name, newValue) } override suspend fun invokeInstanceMethod(scope: Scope, name: String, args: Arguments, onNotFoundResult: (()->Obj?)?): Obj = - instanceScope[name]?.let { - if (it.visibility.isPublic) - it.value.invoke( + instanceScope[name]?.let { rec -> + val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx ?: instanceScope.currentClassCtx + val allowed = if (rec.visibility == net.sergeych.lyng.Visibility.Private) (decl === objClass) else canAccessMember(rec.visibility, decl, caller) + if (!allowed) + scope.raiseError(ObjAccessException(scope, "can't invoke method $name (declared in ${decl?.className ?: "?"})")) + // execute with lexical class context propagated to declaring class + val saved = instanceScope.currentClassCtx + instanceScope.currentClassCtx = decl + try { + rec.value.invoke( instanceScope, this, args) - else - scope.raiseError(ObjAccessException(scope, "can't invoke non-public method $name")) + } finally { + instanceScope.currentClassCtx = saved + } } + ?: run { + // fallback: class-scope function (registered during class body execution) + objClass.classScope?.objects?.get(name)?.let { rec -> + val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx + if (!canAccessMember(rec.visibility, decl, caller)) + scope.raiseError(ObjAccessException(scope, "can't invoke method $name (declared in ${decl?.className ?: "?"})")) + val saved = instanceScope.currentClassCtx + instanceScope.currentClassCtx = decl + try { + rec.value.invoke(instanceScope, this, args) + } finally { + instanceScope.currentClassCtx = saved + } + } + } ?: super.invokeInstanceMethod(scope, name, args, onNotFoundResult) private val publicFields: Map - get() = instanceScope.objects.filter { it.value.visibility.isPublic } + get() = instanceScope.objects.filter { it.value.visibility.isPublic && it.value.type.serializable } override fun toString(): String { val fields = publicFields.map { "${it.key}=${it.value.value}" }.joinToString(",") @@ -120,4 +205,117 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { } return 0 } +} + +/** + * A qualified view over an [ObjInstance] that resolves members starting from a specific ancestor class. + * It does not change identity; it only affects lookup precedence for fields and methods. + */ +class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjClass) : Obj() { + override val objClass: ObjClass get() = instance.objClass + + private fun memberFromAncestor(name: String): ObjRecord? = + instance.objClass.getInstanceMemberFromAncestor(startClass, name) + + override suspend fun readField(scope: Scope, name: String): ObjRecord { + // Qualified field access: prefer mangled storage for the qualified ancestor + val mangled = "${startClass.className}::$name" + instance.instanceScope.objects[mangled]?.let { rec -> + // Visibility: declaring class is the qualified ancestor for mangled storage + val decl = rec.declaringClass ?: startClass + val caller = scope.currentClassCtx + if (!net.sergeych.lyng.canAccessMember(rec.visibility, decl, caller)) + scope.raiseError(ObjAccessException(scope, "can't access field $name (declared in ${decl.className})")) + return rec + } + // Then try instance locals (unmangled) only if startClass is the dynamic class itself + if (startClass === instance.objClass) { + instance.instanceScope[name]?.let { rec -> + val decl = rec.declaringClass ?: instance.objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx + if (!net.sergeych.lyng.canAccessMember(rec.visibility, decl, caller)) + scope.raiseError(ObjAccessException(scope, "can't access field $name (declared in ${decl?.className ?: "?"})")) + return rec + } + } + // Finally try methods/properties starting from ancestor + val r = memberFromAncestor(name) ?: scope.raiseError("no such field: $name") + val decl = r.declaringClass ?: startClass + val caller = scope.currentClassCtx + if (!net.sergeych.lyng.canAccessMember(r.visibility, decl, caller)) + scope.raiseError(ObjAccessException(scope, "can't access field $name (declared in ${decl.className})")) + return when (val value = r.value) { + is net.sergeych.lyng.Statement -> ObjRecord(value.execute(instance.instanceScope.createChildScope(scope.pos, newThisObj = instance)), r.isMutable) + else -> r + } + } + + override suspend fun writeField(scope: Scope, name: String, newValue: Obj) { + // Qualified write: target mangled storage for the ancestor + val mangled = "${startClass.className}::$name" + instance.instanceScope.objects[mangled]?.let { f -> + val decl = f.declaringClass ?: startClass + val caller = scope.currentClassCtx + if (!net.sergeych.lyng.canAccessMember(f.visibility, decl, caller)) + ObjIllegalAssignmentException(scope, "can't assign to field $name (declared in ${decl.className})").raise() + if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() + if (f.value.assign(scope, newValue) == null) f.value = newValue + return + } + // If start is dynamic class, allow unmangled + if (startClass === instance.objClass) { + instance.instanceScope[name]?.let { f -> + val decl = f.declaringClass ?: instance.objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx + if (!net.sergeych.lyng.canAccessMember(f.visibility, decl, caller)) + ObjIllegalAssignmentException(scope, "can't assign to field $name (declared in ${decl?.className ?: "?"})").raise() + if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() + if (f.value.assign(scope, newValue) == null) f.value = newValue + return + } + } + val r = memberFromAncestor(name) ?: scope.raiseError("no such field: $name") + val decl = r.declaringClass ?: startClass + val caller = scope.currentClassCtx + if (!net.sergeych.lyng.canAccessMember(r.visibility, decl, caller)) + ObjIllegalAssignmentException(scope, "can't assign to field $name (declared in ${decl.className})").raise() + if (!r.isMutable) scope.raiseError("can't assign to read-only field: $name") + if (r.value.assign(scope, newValue) == null) r.value = newValue + } + + override suspend fun invokeInstanceMethod(scope: Scope, name: String, args: Arguments, onNotFoundResult: (() -> Obj?)?): Obj { + // Qualified method dispatch must start from the specified ancestor, not from the instance scope. + memberFromAncestor(name)?.let { rec -> + val decl = rec.declaringClass ?: startClass + val caller = scope.currentClassCtx + if (!net.sergeych.lyng.canAccessMember(rec.visibility, decl, caller)) + scope.raiseError(ObjAccessException(scope, "can't invoke method $name (declared in ${decl.className})")) + val saved = instance.instanceScope.currentClassCtx + instance.instanceScope.currentClassCtx = decl + try { + return rec.value.invoke(instance.instanceScope, instance, args) + } finally { + instance.instanceScope.currentClassCtx = saved + } + } + // If the qualifier is the dynamic class itself, allow instance-scope methods as a fallback + if (startClass === instance.objClass) { + instance.instanceScope[name]?.let { rec -> + val decl = rec.declaringClass ?: instance.objClass.findDeclaringClassOf(name) + val caller = scope.currentClassCtx + if (!net.sergeych.lyng.canAccessMember(rec.visibility, decl, caller)) + scope.raiseError(ObjAccessException(scope, "can't invoke method $name (declared in ${decl?.className ?: "?"})")) + val saved = instance.instanceScope.currentClassCtx + instance.instanceScope.currentClassCtx = decl + try { + return rec.value.invoke(instance.instanceScope, instance, args) + } finally { + instance.instanceScope.currentClassCtx = saved + } + } + } + return onNotFoundResult?.invoke() ?: scope.raiseSymbolNotFound(name) + } + + override fun toString(): String = instance.toString() } \ No newline at end of file diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt index 35a2db9..0c4c01e 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt @@ -25,7 +25,7 @@ import net.sergeych.lynon.LynonType /** * Special variant of [ObjClass] to be used in [ObjInstance], e.g. for Lyng compiled classes */ -class ObjInstanceClass(val name: String) : ObjClass(name) { +class ObjInstanceClass(val name: String, vararg parents: ObjClass) : ObjClass(name, *parents) { override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj { val args = decoder.decodeAnyList(scope) @@ -41,7 +41,6 @@ class ObjInstanceClass(val name: String) : ObjClass(name) { init { addFn("toString", true) { - println("-------------- tos! --------------") ObjString(thisObj.toString()) } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjMutex.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjMutex.kt index fff9dc4..faa164e 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjMutex.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjMutex.kt @@ -33,6 +33,9 @@ class ObjMutex(val mutex: Mutex): Obj() { }.apply { addFn("withLock") { val f = requiredArg(0) + // Execute user lambda directly in the current scope to preserve the active scope + // ancestry across suspension points. The lambda still constructs a ClosureScope + // on top of this frame, and parseLambdaExpression sets skipScopeCreation for its body. thisAs().mutex.withLock { f.execute(this) } } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt index 5d740d4..7496b32 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt @@ -27,6 +27,8 @@ data class ObjRecord( var value: Obj, val isMutable: Boolean, val visibility: Visibility = Visibility.Public, + /** If non-null, denotes the class that declared this member (field/method). */ + val declaringClass: ObjClass? = null, var importedFrom: Scope? = null, val isTransient: Boolean = false, val type: Type = Type.Other diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt index 76761f2..491236f 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -253,6 +253,49 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r } } +/** Cast operator reference: left `as` rightType or `as?` (nullable). */ +class CastRef( + private val valueRef: ObjRef, + private val typeRef: ObjRef, + private val isNullable: Boolean, + private val atPos: Pos, +) : ObjRef { + override suspend fun get(scope: Scope): ObjRecord { + val v0 = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) valueRef.evalValue(scope) else valueRef.get(scope).value + val t = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) typeRef.evalValue(scope) else typeRef.get(scope).value + val target = (t as? ObjClass) ?: scope.raiseClassCastError("${'$'}t is not the class instance") + // unwrap qualified views + val v = when (v0) { + is ObjQualifiedView -> v0.instance + else -> v0 + } + return if (v.isInstanceOf(target)) { + // For instances, return a qualified view to enforce ancestor-start dispatch + if (v is ObjInstance) ObjQualifiedView(v, target).asReadonly else v.asReadonly + } else { + if (isNullable) ObjNull.asReadonly else scope.raiseClassCastError( + "Cannot cast ${'$'}{(v as? Obj)?.objClass?.className ?: v::class.simpleName} to ${'$'}{target.className}" + ) + } + } +} + +/** Qualified `this@Type`: resolves to a view of current `this` starting dispatch from the ancestor Type. */ +class QualifiedThisRef(private val typeName: String, private val atPos: Pos) : ObjRef { + override suspend fun get(scope: Scope): ObjRecord { + val thisObj = scope.thisObj + val t = scope[typeName]?.value as? ObjClass + ?: scope.raiseError("unknown type $typeName") + val inst = (thisObj as? ObjInstance) + ?: scope.raiseClassCastError("this is not an instance") + if (!inst.objClass.allParentsSet.contains(t) && inst.objClass !== t) + scope.raiseClassCastError( + "Qualifier ${'$'}{t.className} is not an ancestor of ${'$'}{inst.objClass.className} (order: ${'$'}{inst.objClass.renderLinearization(true)})" + ) + return ObjQualifiedView(inst, t).asReadonly + } +} + /** Assignment compound op: target op= value */ class AssignOpRef( private val op: BinOp, @@ -1168,13 +1211,20 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef { override suspend fun get(scope: Scope): ObjRecord { scope.pos = atPos + // 1) Try fast slot/local if (!PerfFlags.LOCAL_SLOT_PIC) { - scope.getSlotIndexOf(name)?.let { + scope.getSlotIndexOf(name)?.let { if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.localVarPicHit++ - return scope.getSlotRecord(it) + return scope.getSlotRecord(it) } if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.localVarPicMiss++ - return scope[name] ?: scope.raiseError("symbol not defined: '$name'") + // 2) Fallback to current-scope object or field on `this` + scope[name]?.let { return it } + val th = scope.thisObj + return when (th) { + is Obj -> th.readField(scope, name) + else -> scope.raiseError("symbol not defined: '$name'") + } } val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount()) val slot = if (hit) cachedSlot else resolveSlot(scope) @@ -1185,19 +1235,37 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef { return scope.getSlotRecord(slot) } if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.localVarPicMiss++ - return scope[name] ?: scope.raiseError("symbol not defined: '$name'") + // 2) Fallback name in scope or field on `this` + scope[name]?.let { return it } + val th = scope.thisObj + return when (th) { + is Obj -> th.readField(scope, name) + else -> scope.raiseError("symbol not defined: '$name'") + } } override suspend fun evalValue(scope: Scope): Obj { scope.pos = atPos if (!PerfFlags.LOCAL_SLOT_PIC) { scope.getSlotIndexOf(name)?.let { return scope.getSlotRecord(it).value } - return (scope[name] ?: scope.raiseError("symbol not defined: '$name'")).value + // fallback to current-scope object or field on `this` + scope[name]?.let { return it.value } + val th = scope.thisObj + return when (th) { + is Obj -> th.readField(scope, name).value + else -> scope.raiseError("symbol not defined: '$name'") + } } val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount()) val slot = if (hit) cachedSlot else resolveSlot(scope) if (slot >= 0) return scope.getSlotRecord(slot).value - return (scope[name] ?: scope.raiseError("symbol not defined: '$name'")).value + // Fallback name in scope or field on `this` + scope[name]?.let { return it.value } + val th = scope.thisObj + return when (th) { + is Obj -> th.readField(scope, name).value + else -> scope.raiseError("symbol not defined: '$name'") + } } override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) { @@ -1209,10 +1277,18 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef { rec.value = newValue return } - val stored = scope[name] ?: scope.raiseError("symbol not defined: '$name'") - if (stored.isMutable) stored.value = newValue - else scope.raiseError("Cannot assign to immutable value") - return + scope[name]?.let { stored -> + if (stored.isMutable) stored.value = newValue + else scope.raiseError("Cannot assign to immutable value") + return + } + // Fallback: write to field on `this` + val th = scope.thisObj + if (th is Obj) { + th.writeField(scope, name, newValue) + return + } + scope.raiseError("symbol not defined: '$name'") } val slot = if (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount()) cachedSlot else resolveSlot(scope) if (slot >= 0) { @@ -1221,9 +1297,17 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef { rec.value = newValue return } - val stored = scope[name] ?: scope.raiseError("symbol not defined: '$name'") - if (stored.isMutable) stored.value = newValue - else scope.raiseError("Cannot assign to immutable value") + scope[name]?.let { stored -> + if (stored.isMutable) stored.value = newValue + else scope.raiseError("Cannot assign to immutable value") + return + } + val th = scope.thisObj + if (th is Obj) { + th.writeField(scope, name, newValue) + return + } + scope.raiseError("symbol not defined: '$name'") } } @@ -1298,11 +1382,32 @@ class FastLocalVarRef( val ownerValid = isOwnerValidFor(scope) val slot = if (ownerValid && cachedSlot >= 0) cachedSlot else resolveSlotInAncestry(scope) val actualOwner = cachedOwnerScope - if (slot < 0 || actualOwner == null) scope.raiseError("local '$name' is not available in this scope") - if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) { - if (ownerValid) net.sergeych.lyng.PerfStats.fastLocalHit++ else net.sergeych.lyng.PerfStats.fastLocalMiss++ + if (slot >= 0 && actualOwner != null) { + if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) { + if (ownerValid) net.sergeych.lyng.PerfStats.fastLocalHit++ else net.sergeych.lyng.PerfStats.fastLocalMiss++ + } + return actualOwner.getSlotRecord(slot) } - return actualOwner.getSlotRecord(slot) + // Try per-frame local binding maps in the ancestry first (locals declared in frames) + run { + var s: Scope? = scope + while (s != null) { + s.localBindings[name]?.let { return it } + s = s.parent + } + } + // Try to find a direct local binding in the current ancestry (without invoking name resolution that may prefer fields) + var s: Scope? = scope + while (s != null) { + s.objects[name]?.let { return it } + s = s.parent + } + // Fallback to standard name lookup (locals or closure chain) if the slot owner changed across suspension + scope[name]?.let { return it } + // As a last resort, treat as field on `this` + val th = scope.thisObj + if (th is Obj) return th.readField(scope, name) + scope.raiseError("local '$name' is not available in this scope") } override suspend fun evalValue(scope: Scope): Obj { @@ -1310,8 +1415,26 @@ class FastLocalVarRef( val ownerValid = isOwnerValidFor(scope) val slot = if (ownerValid && cachedSlot >= 0) cachedSlot else resolveSlotInAncestry(scope) val actualOwner = cachedOwnerScope - if (slot < 0 || actualOwner == null) scope.raiseError("local '$name' is not available in this scope") - return actualOwner.getSlotRecord(slot).value + if (slot >= 0 && actualOwner != null) return actualOwner.getSlotRecord(slot).value + // Try per-frame local binding maps in the ancestry first + run { + var s: Scope? = scope + while (s != null) { + s.localBindings[name]?.let { return it.value } + s = s.parent + } + } + // Try to find a direct local binding in the current ancestry first + var s: Scope? = scope + while (s != null) { + s.objects[name]?.let { return it.value } + s = s.parent + } + // Fallback to standard name lookup (locals or closure chain) + scope[name]?.let { return it.value } + val th = scope.thisObj + if (th is Obj) return th.readField(scope, name).value + scope.raiseError("local '$name' is not available in this scope") } override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) { @@ -1319,10 +1442,37 @@ class FastLocalVarRef( val owner = if (isOwnerValidFor(scope)) cachedOwnerScope else null val slot = if (owner != null && cachedSlot >= 0) cachedSlot else resolveSlotInAncestry(scope) val actualOwner = cachedOwnerScope - if (slot < 0 || actualOwner == null) scope.raiseError("local '$name' is not available in this scope") - val rec = actualOwner.getSlotRecord(slot) - if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") - rec.value = newValue + if (slot >= 0 && actualOwner != null) { + val rec = actualOwner.getSlotRecord(slot) + if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") + rec.value = newValue + return + } + // Try per-frame local binding maps in the ancestry first + run { + var s: Scope? = scope + while (s != null) { + val rec = s.localBindings[name] + if (rec != null) { + if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") + rec.value = newValue + return + } + s = s.parent + } + } + // Fallback to standard name lookup + scope[name]?.let { stored -> + if (stored.isMutable) stored.value = newValue + else scope.raiseError("Cannot assign to immutable value") + return + } + val th = scope.thisObj + if (th is Obj) { + th.writeField(scope, name, newValue) + return + } + scope.raiseError("local '$name' is not available in this scope") } } @@ -1385,3 +1535,5 @@ class AssignRef( return v.asReadonly } } + + // (duplicate LocalVarRef removed; the canonical implementation is defined earlier in this file) \ No newline at end of file diff --git a/lynglib/src/commonTest/kotlin/MIC3MroTest.kt b/lynglib/src/commonTest/kotlin/MIC3MroTest.kt new file mode 100644 index 0000000..dec9bef --- /dev/null +++ b/lynglib/src/commonTest/kotlin/MIC3MroTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2025 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. + * + */ + +/* + * C3 MRO tests for Multiple Inheritance + */ + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.eval +import kotlin.test.Test + +class MIC3MroTest { + + @Test + fun diamondConstructorRunsSharedAncestorOnce() = runTest { + eval( + """ + var topInit = 0 + + class Top() { + // increment module-scope counter in instance initializer; runs once per Top-subobject + var tick = (topInit = topInit + 1) + } + + class Left() : Top() + class Right() : Top() + class Bottom() : Left(), Right() + + val b = Bottom() + assertEquals(1, topInit) + """.trimIndent() + ) + } + + @Test + fun methodResolutionFollowsC3() = runTest { + // For the classic diamond D(B,C), B and C derive from A; C3 should result in D -> B -> C -> A + eval( + """ + class A() { fun common() { "A" } } + class B() : A() { fun common() { "B" } } + class C() : A() { fun common() { "C" } } + class D() : B(), C() + + val d = D() + // Unqualified must pick B.common() according to C3 when direct bases are (B, C) + assertEquals("B", d.common()) + // Qualified disambiguation via casts still works + assertEquals("C", (d as C).common()) + assertEquals("A", (d as A).common()) + """.trimIndent() + ) + } +} diff --git a/lynglib/src/commonTest/kotlin/MIDiagnosticsTest.kt b/lynglib/src/commonTest/kotlin/MIDiagnosticsTest.kt new file mode 100644 index 0000000..6e9c63f --- /dev/null +++ b/lynglib/src/commonTest/kotlin/MIDiagnosticsTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2025 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. + * + */ + +/* + * Diagnostics tests for Multiple Inheritance (MI) + */ + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.eval +import kotlin.test.Test +import kotlin.test.assertFails +import kotlin.test.assertTrue + +class MIDiagnosticsTest { + + @Test + fun missingMemberIncludesLinearizationAndHint() = runTest { + val ex = assertFails { + eval( + """ + class Foo(val a) { fun runA() { "ResultA:" + a } } + class Bar(val b) { fun runB() { "ResultB:" + b } } + class FooBar(a,b) : Foo(a), Bar(b) { } + val fb = FooBar(1,2) + fb.qux() + """.trimIndent() + ) + } + val msg = ex.message ?: "" + assertTrue(msg.contains("no such member: qux"), "must mention missing member name") + assertTrue(msg.contains("FooBar"), "must mention receiver class name") + assertTrue(msg.contains("Considered order:"), "must include linearization header") + assertTrue(msg.contains("FooBar") && msg.contains("Foo") && msg.contains("Bar"), "must list classes in order") + assertTrue(msg.contains("this@") || msg.contains("(obj as"), "must suggest qualification or cast") + } + + @Test + fun missingFieldIncludesLinearization() = runTest { + val ex = assertFails { + eval( + """ + class Foo(val a) { var tag = "F" } + class Bar(val b) { var tag = "B" } + class FooBar(a,b) : Foo(a), Bar(b) { } + val fb = FooBar(1,2) + fb.unknownField + """.trimIndent() + ) + } + val msg = ex.message ?: "" + assertTrue(msg.contains("no such field: unknownField")) + assertTrue(msg.contains("FooBar")) + assertTrue(msg.contains("Considered order:")) + } + + @Test + fun invalidQualifiedThisReportsAncestorError() = runTest { + assertFails { + eval( + """ + class Foo() { fun f() { "F" } } + class Bar() { fun g() { "G" } } + class Baz() : Foo() { + fun bad() { this@Bar.g() } + } + val b = Baz() + b.bad() + """.trimIndent() + ) + } + } + + @Test + fun castFailureMentionsActualAndTargetTypes() = runTest { + val ex = assertFails { + eval( + """ + class Foo() { } + class Bar() { } + val b = Bar() + (b as Foo) + """.trimIndent() + ) + } + val msg = ex.message ?: "" + // message like: Cannot cast Bar to Foo (be tolerant across targets) + val lower = msg.lowercase() + assertTrue(lower.contains("cast"), "message should mention cast") + assertTrue(msg.contains("Bar") || msg.contains("Foo"), "should mention at least one of the types") + } +} diff --git a/lynglib/src/commonTest/kotlin/MIQualifiedDispatchTest.kt b/lynglib/src/commonTest/kotlin/MIQualifiedDispatchTest.kt new file mode 100644 index 0000000..f9aa52f --- /dev/null +++ b/lynglib/src/commonTest/kotlin/MIQualifiedDispatchTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2025 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. + * + */ + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.eval +import kotlin.test.Test + +class MIQualifiedDispatchTest { + + @Test + fun testQualifiedMethodResolution() = runTest { + eval( + """ + class Foo(val a) { + fun common() { "A" } + fun runA() { "ResultA:" + a } + } + + class Bar(val b) { + fun common() { "B" } + fun runB() { "ResultB:" + b } + } + + class FooBar(a,b) : Foo(a), Bar(b) { } + + val fb = FooBar(1,2) + + // unqualified picks leftmost base + assertEquals("A", fb.common()) + + // cast-based disambiguation + assertEquals("B", (fb as Bar).common()) + assertEquals("A", (fb as Foo).common()) + + // Note: wrappers using this@Type inside FooBar body will be validated later + // when class-body method registration is finalized. + """.trimIndent() + ) + } + + @Test + fun testQualifiedFieldReadWrite() = runTest { + eval( + """ + class Foo(val a) { var tag = "F" } + class Bar(val b) { var tag = "B" } + class FooBar(a,b) : Foo(a), Bar(b) { } + + val fb = FooBar(1,2) + // unqualified resolves to leftmost base + assertEquals("F", fb.tag) + // qualified reads via casts + assertEquals("F", (fb as Foo).tag) + assertEquals("B", (fb as Bar).tag) + + // unqualified write updates leftmost base + fb.tag = "X" + assertEquals("X", fb.tag) + assertEquals("X", (fb as Foo).tag) + assertEquals("B", (fb as Bar).tag) + + // qualified write via cast targets Bar + (fb as Bar).tag = "Y" + assertEquals("X", (fb as Foo).tag) + assertEquals("Y", (fb as Bar).tag) + """.trimIndent() + ) + } + + @Test + fun testCastsAndSafeCall() = runTest { + eval( + """ + class Foo(val a) { fun runA() { "ResultA:" + a } } + class Bar(val b) { fun runB() { "ResultB:" + b } } + class Buzz : Bar(3) + val buzz = Buzz() + assertEquals("ResultB:3", buzz.runB()) + assertEquals("ResultB:3", (buzz as? Bar)?.runB()) + assertEquals(null, (buzz as? Foo)?.runA()) + """.trimIndent() + ) + } +} diff --git a/lynglib/src/commonTest/kotlin/MIVisibilityTest.kt b/lynglib/src/commonTest/kotlin/MIVisibilityTest.kt new file mode 100644 index 0000000..e4f4ca2 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/MIVisibilityTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2025 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. + * + */ + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.eval +import kotlin.test.Test +import kotlin.test.assertFails + +class MIVisibilityTest { + + @Test + fun privateMethodNotVisibleInSubclass() = runTest { + val code = """ + class Foo() { + private fun secret() { "S" } + } + class Bar() : Foo() { + fun trySecret() { this@Foo.secret() } + } + val b = Bar() + // calling a wrapper that tries to access Foo.secret must fail + b.trySecret() + """.trimIndent() + assertFails { eval(code) } + } + + @Test + fun protectedMethodNotVisibleFromUnrelatedEvenViaCast() = runTest { + val code = """ + class Foo() { protected fun prot() { "P" } } + class Bar() : Foo() { } + val b = Bar() + // Unrelated context tries to call through cast — should fail + (b as Foo).prot() + """.trimIndent() + assertFails { eval(code) } + } + + @Test + fun privateFieldNotVisibleInSubclass() = runTest { + val code = """ + class Foo() { private val x = "X" } + class Bar() : Foo() { fun getX() { this@Foo.x } } + val b = Bar() + b.getX() + """.trimIndent() + assertFails { eval(code) } + } + + @Test + fun protectedFieldNotVisibleFromUnrelatedEvenViaCast() = runTest { + // Not allowed from unrelated, even via cast + val code = """ + class Foo() { protected val y = "Y" } + class Bar() : Foo() { } + val b = Bar() + (b as Foo).y + """.trimIndent() + assertFails { eval(code) } + } +} diff --git a/lynglib/src/commonTest/kotlin/ParallelLocalScopeTest.kt b/lynglib/src/commonTest/kotlin/ParallelLocalScopeTest.kt new file mode 100644 index 0000000..289d921 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/ParallelLocalScopeTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2025 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. + * + */ + +/* + * Focused regression test for local variable visibility across suspension inside withLock { } + */ + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.eval +import kotlin.test.Test + +class ParallelLocalScopeTest { + + @Test + fun localsSurviveSuspensionInsideWithLock() = runTest { + // Minimal reproduction of the pattern used in ScriptTest.testParallels2 + eval( + """ + class AtomicCounter { + private val m = Mutex() + private var counter = 0 + + fun increment() { + m.withLock { + val a = counter + delay(1) + counter = a + 1 + } + } + + fun getCounter() { counter } + } + + val ac = AtomicCounter() + // Single-threaded increments should work if locals survive after delay + for (i in 1..3) ac.increment() + assertEquals(3, ac.getCounter()) + """.trimIndent() + ) + } +} diff --git a/lynglib/src/commonTest/kotlin/TestInheritance.kt b/lynglib/src/commonTest/kotlin/TestInheritance.kt new file mode 100644 index 0000000..053e00a --- /dev/null +++ b/lynglib/src/commonTest/kotlin/TestInheritance.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2025 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. + * + */ + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.eval +import kotlin.test.Test + +/* + * Copyright 2025 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. + * + */ + +class TestInheritance { + + @Test + fun testInheritanceSpecification() = runTest { + eval(""" + // Multiple inheritance specification test (spec only, parser/interpreter TBD) + + // Parent A: exposes a val and a var, and a method with a name that collides with Bar.common() + class Foo(val a) { + var tag = "F" + + fun runA() { + "ResultA:" + a + } + + fun common() { + "CommonA" + } + + // this can only be called from Foo (not from subclasses): + private fun privateInFoo() { + } + + // this can be called from Foo and any subclass (including MI subclasses): + protected fun protectedInFoo() { + } +} + +// Parent B: also exposes a val and a var with the same name to test field inheritance and conflict rules +class Bar(val b) { + var tag = "B" + + fun runB() { + "ResultB:" + b + } + + fun common() { + "CommonB" + } +} + +// With multiple inheritance, base constructors are called in the order of declaration, +// and each ancestor is initialized at most once (diamonds are de-duplicated): +class FooBar(a, b) : Foo(a), Bar(b) { + + // Ambiguous method name "common" can be disambiguated: + fun commonFromFoo() { + // explicit qualification by ancestor type: + this@Foo.common() + // or by cast: + (this as Foo).common() + } + + fun commonFromBar() { + this@Bar.common() + (this as Bar).common() + } + + // Accessing inherited fields (val/var) respects the same resolution rules: + fun tagFromFoo() { this@Foo.tag } + fun tagFromBar() { this@Bar.tag } +} + +val fb = FooBar(1, 2) + +// Methods with distinct names from different bases work: +assertEquals("ResultA:1", fb.runA()) +assertEquals("ResultB:2", fb.runB()) + +// If we call an ambiguous method unqualified, the first in MRO (leftmost base) is used: +assertEquals("CommonA", fb.common()) + +// We can call a specific one via explicit qualification or cast: +assertEquals("CommonB", (fb as Bar).common()) +assertEquals("CommonA", (fb as Foo).common()) + +// Or again via explicit casts (wrappers may be validated separately): +assertEquals("CommonB", (fb as Bar).common()) +assertEquals("CommonA", (fb as Foo).common()) + +// Inheriting val/var: +// - Reading an ambiguous var/val selects the first along MRO (Foo.tag initially): +assertEquals("F", fb.tag) +// - Qualified access returns the chosen ancestor’s member: +assertEquals("F", (fb as Foo).tag) +assertEquals("B", (fb as Bar).tag) + +// - Writing an ambiguous var writes to the same selected member (first in MRO): +fb.tag = "X" +assertEquals("X", fb.tag) // unqualified resolves to Foo.tag +assertEquals("X", (fb as Foo).tag) // Foo.tag updated +assertEquals("B", (fb as Bar).tag) // Bar.tag unchanged + +// - Qualified write via cast updates the specific ancestor’s storage: +(fb as Bar).tag = "Y" +assertEquals("X", (fb as Foo).tag) +assertEquals("Y", (fb as Bar).tag) + +// A simple single-inheritance subclass still works: +class Buzz : Bar(3) +val buzz = Buzz() + +assertEquals("ResultB:3", buzz.runB()) + +// Optional cast returns null if cast is not possible; use safe-call with it: +assertEquals("ResultB:3", (buzz as? Bar)?.runB()) +assertEquals(null, (buzz as? Foo)?.runA()) + +// Visibility (spec only): +// - Foo.privateInFoo() is accessible only inside Foo body; even FooBar cannot call it, +// including with this@Foo or casts. Attempting to do so must be a compile-time error. +// - Foo.protectedInFoo() is accessible inside Foo and any subclass bodies (including FooBar), +// but not from unrelated classes/instances. + """.trimIndent()) + } +} \ No newline at end of file diff --git a/lynglib/src/jvmTest/kotlin/BookAllocationProfileTest.kt b/lynglib/src/jvmTest/kotlin/BookAllocationProfileTest.kt new file mode 100644 index 0000000..f59c05e --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/BookAllocationProfileTest.kt @@ -0,0 +1,336 @@ +/* + * Copyright 2025 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. + * + */ + +import kotlinx.coroutines.runBlocking +import net.sergeych.lyng.PerfFlags +import java.io.File +import java.lang.management.GarbageCollectorMXBean +import java.lang.management.ManagementFactory +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.io.path.extension +import kotlin.random.Random +import kotlin.system.measureNanoTime +import kotlin.test.Test + +class BookAllocationProfileTest { + + private fun outFile(): File = File("lynglib/build/book_alloc_profile.txt") + + private fun writeHeader(f: File) { + if (!f.parentFile.exists()) f.parentFile.mkdirs() + f.writeText("[DEBUG_LOG] Book allocation/time profiling (JVM)\n") + f.appendText("[DEBUG_LOG] All sizes in bytes; time in ns (lower is better).\n") + } + + private fun appendLine(f: File, s: String) { f.appendText(s + "\n") } + + // Optional STDERR filter to hide benign warnings during profiling runs + private inline fun withFilteredStderr(vararg suppressContains: String, block: () -> T): T { + val orig = System.err + val filtering = java.io.PrintStream(object : java.io.OutputStream() { + private val buf = StringBuilder() + override fun write(b: Int) { + if (b == '\n'.code) { + val line = buf.toString() + val suppress = suppressContains.any { line.contains(it) } + if (!suppress) orig.println(line) + buf.setLength(0) + } else buf.append(b.toChar()) + } + }) + return try { + System.setErr(filtering) + block() + } finally { + System.setErr(orig) + } + } + + private fun forceGc() { + // Best-effort GC to stabilize measurements + repeat(3) { + System.gc() + try { Thread.sleep(25) } catch (_: InterruptedException) {} + } + } + + private fun usedHeap(): Long { + val mem = ManagementFactory.getMemoryMXBean().heapMemoryUsage + return mem.used + } + + private suspend fun runBooksOnce(): Unit = runBlocking { + // Mirror BookTest set + runDocTests("../docs/tutorial.md") + runDocTests("../docs/math.md") + runDocTests("../docs/advanced_topics.md") + runDocTests("../docs/OOP.md") + runDocTests("../docs/Real.md") + runDocTests("../docs/List.md") + runDocTests("../docs/Range.md") + runDocTests("../docs/Set.md") + runDocTests("../docs/Map.md") + runDocTests("../docs/Buffer.md") + // Samples folder, bookMode=true + for (bt in Files.list(Paths.get("../docs/samples")).toList()) { + if (bt.extension == "md") runDocTests(bt.toString(), bookMode = true) + } + runDocTests("../docs/declaring_arguments.md") + runDocTests("../docs/exceptions_handling.md") + runDocTests("../docs/time.md") + runDocTests("../docs/parallelism.md") + runDocTests("../docs/RingBuffer.md") + runDocTests("../docs/Iterable.md") + } + + private data class ProfileResult(val timeNs: Long, val allocBytes: Long) + + private suspend fun profileRun(): ProfileResult { + forceGc() + val before = usedHeap() + val elapsed = measureNanoTime { + withFilteredStderr("ScriptFlowIsNoMoreCollected") { + runBooksOnce() + } + } + forceGc() + val after = usedHeap() + val alloc = (after - before).coerceAtLeast(0) + return ProfileResult(elapsed, alloc) + } + + private data class GcSnapshot(val count: Long, val timeMs: Long) + private fun gcSnapshot(): GcSnapshot { + var c = 0L + var t = 0L + for (gc: GarbageCollectorMXBean in ManagementFactory.getGarbageCollectorMXBeans()) { + c += (gc.collectionCount.takeIf { it >= 0 } ?: 0) + t += (gc.collectionTime.takeIf { it >= 0 } ?: 0) + } + return GcSnapshot(c, t) + } + + // --- Optional JFR support via reflection (works only on JDKs with Flight Recorder) --- + private class JfrHandle(val rec: Any, val dump: (File) -> Unit, val stop: () -> Unit) + + private fun jfrStartIfRequested(name: String): JfrHandle? { + val enabled = System.getProperty("lyng.jfr")?.toBoolean() == true + if (!enabled) return null + return try { + val recCl = Class.forName("jdk.jfr.Recording") + val ctor = recCl.getDeclaredConstructor() + val rec = ctor.newInstance() + val setName = recCl.methods.firstOrNull { it.name == "setName" && it.parameterTypes.size == 1 } + setName?.invoke(rec, "Lyng-$name") + val start = recCl.methods.first { it.name == "start" && it.parameterTypes.isEmpty() } + start.invoke(rec) + val stop = recCl.methods.first { it.name == "stop" && it.parameterTypes.isEmpty() } + val dump = recCl.methods.firstOrNull { it.name == "dump" && it.parameterTypes.size == 1 } + val dumper: (File) -> Unit = if (dump != null) { + { f -> dump.invoke(rec, f.toPath()) } + } else { + { _ -> } + } + JfrHandle(rec, dumper) { stop.invoke(rec) } + } catch (e: Throwable) { + // JFR requested but not available; note once via stdout and proceed without it + try { + println("[DEBUG_LOG] JFR not available on this JVM; run with Oracle/OpenJDK 11+ to enable -Dlyng.jfr=true") + } catch (_: Throwable) {} + null + } + } + + private fun intProp(name: String, def: Int): Int = + System.getProperty(name)?.toIntOrNull() ?: def + + private fun boolProp(name: String, def: Boolean): Boolean = + System.getProperty(name)?.toBoolean() ?: def + + private data class FlagSnapshot( + val RVAL_FASTPATH: Boolean, + val PRIMITIVE_FASTOPS: Boolean, + val ARG_BUILDER: Boolean, + val ARG_SMALL_ARITY_12: Boolean, + val FIELD_PIC: Boolean, + val METHOD_PIC: Boolean, + val FIELD_PIC_SIZE_4: Boolean, + val METHOD_PIC_SIZE_4: Boolean, + val INDEX_PIC: Boolean, + val INDEX_PIC_SIZE_4: Boolean, + val SCOPE_POOL: Boolean, + val PIC_DEBUG_COUNTERS: Boolean, + ) { + fun restore() { + PerfFlags.RVAL_FASTPATH = RVAL_FASTPATH + PerfFlags.PRIMITIVE_FASTOPS = PRIMITIVE_FASTOPS + PerfFlags.ARG_BUILDER = ARG_BUILDER + PerfFlags.ARG_SMALL_ARITY_12 = ARG_SMALL_ARITY_12 + PerfFlags.FIELD_PIC = FIELD_PIC + PerfFlags.METHOD_PIC = METHOD_PIC + PerfFlags.FIELD_PIC_SIZE_4 = FIELD_PIC_SIZE_4 + PerfFlags.METHOD_PIC_SIZE_4 = METHOD_PIC_SIZE_4 + PerfFlags.INDEX_PIC = INDEX_PIC + PerfFlags.INDEX_PIC_SIZE_4 = INDEX_PIC_SIZE_4 + PerfFlags.SCOPE_POOL = SCOPE_POOL + PerfFlags.PIC_DEBUG_COUNTERS = PIC_DEBUG_COUNTERS + } + } + + private fun snapshotFlags() = FlagSnapshot( + RVAL_FASTPATH = PerfFlags.RVAL_FASTPATH, + PRIMITIVE_FASTOPS = PerfFlags.PRIMITIVE_FASTOPS, + ARG_BUILDER = PerfFlags.ARG_BUILDER, + ARG_SMALL_ARITY_12 = PerfFlags.ARG_SMALL_ARITY_12, + FIELD_PIC = PerfFlags.FIELD_PIC, + METHOD_PIC = PerfFlags.METHOD_PIC, + FIELD_PIC_SIZE_4 = PerfFlags.FIELD_PIC_SIZE_4, + METHOD_PIC_SIZE_4 = PerfFlags.METHOD_PIC_SIZE_4, + INDEX_PIC = PerfFlags.INDEX_PIC, + INDEX_PIC_SIZE_4 = PerfFlags.INDEX_PIC_SIZE_4, + SCOPE_POOL = PerfFlags.SCOPE_POOL, + PIC_DEBUG_COUNTERS = PerfFlags.PIC_DEBUG_COUNTERS, + ) + + private fun median(values: List): Long { + if (values.isEmpty()) return 0 + val s = values.sorted() + val mid = s.size / 2 + return if (s.size % 2 == 1) s[mid] else ((s[mid - 1] + s[mid]) / 2) + } + + private suspend fun runScenario( + name: String, + prepare: () -> Unit, + repeats: Int = 3, + out: (String) -> Unit + ): ProfileResult { + val warmup = intProp("lyng.profile.warmup", 1) + val reps = intProp("lyng.profile.repeats", repeats) + // JFR + val jfr = jfrStartIfRequested(name) + if (System.getProperty("lyng.jfr")?.toBoolean() == true && jfr == null) { + out("[DEBUG_LOG] JFR: requested but not available on this JVM") + } + // Warm-up before GC snapshot (some profilers prefer this) + prepare() + repeat(warmup) { profileRun() } + // GC baseline + val gc0 = gcSnapshot() + val times = ArrayList(repeats) + val allocs = ArrayList(repeats) + repeat(reps) { + val r = profileRun() + times += r.timeNs + allocs += r.allocBytes + } + val pr = ProfileResult(median(times), median(allocs)) + val gc1 = gcSnapshot() + val gcCountDelta = (gc1.count - gc0.count).coerceAtLeast(0) + val gcTimeDelta = (gc1.timeMs - gc0.timeMs).coerceAtLeast(0) + out("[DEBUG_LOG] time=${pr.timeNs} ns, alloc=${pr.allocBytes} B (median of ${reps}), GC(count=${gcCountDelta}, timeMs=${gcTimeDelta})") + // Stop and dump JFR if enabled + if (jfr != null) { + try { + jfr.stop() + val dumpFile = File("lynglib/build/jfr_${name}.jfr") + jfr.dump(dumpFile) + out("[DEBUG_LOG] JFR dumped: ${dumpFile.path}") + } catch (_: Throwable) {} + } + return pr + } + + @Test + fun profile_books_allocations_and_time() = runTestBlocking { + val f = outFile() + writeHeader(f) + + fun log(s: String) = appendLine(f, s) + + val saved = snapshotFlags() + try { + data class Scenario(val label: String, val title: String, val prep: () -> Unit) + val scenarios = mutableListOf() + // Baseline A + scenarios += Scenario("A", "JVM defaults") { + saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false + } + // Most flags OFF B + scenarios += Scenario("B", "most perf flags OFF") { + saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false + PerfFlags.RVAL_FASTPATH = false + PerfFlags.PRIMITIVE_FASTOPS = false + PerfFlags.ARG_BUILDER = false + PerfFlags.ARG_SMALL_ARITY_12 = false + PerfFlags.FIELD_PIC = false + PerfFlags.METHOD_PIC = false + PerfFlags.FIELD_PIC_SIZE_4 = false + PerfFlags.METHOD_PIC_SIZE_4 = false + PerfFlags.INDEX_PIC = false + PerfFlags.INDEX_PIC_SIZE_4 = false + PerfFlags.SCOPE_POOL = false + } + // Defaults with INDEX_PIC size 2 C + scenarios += Scenario("C", "defaults except INDEX_PIC_SIZE_4=false") { + saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false + PerfFlags.INDEX_PIC = true; PerfFlags.INDEX_PIC_SIZE_4 = false + } + + // One-flag toggles relative to A + scenarios += Scenario("D", "A with RVAL_FASTPATH=false") { + saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false; PerfFlags.RVAL_FASTPATH = false + } + scenarios += Scenario("E", "A with PRIMITIVE_FASTOPS=false") { + saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false; PerfFlags.PRIMITIVE_FASTOPS = false + } + scenarios += Scenario("F", "A with INDEX_PIC=false") { + saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false; PerfFlags.INDEX_PIC = false + } + scenarios += Scenario("G", "A with SCOPE_POOL=false") { + saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false; PerfFlags.SCOPE_POOL = false + } + + val shuffle = boolProp("lyng.profile.shuffle", true) + val order = if (shuffle) scenarios.shuffled(Random(System.nanoTime())) else scenarios + + val results = mutableMapOf() + for (sc in order) { + log("[DEBUG_LOG] Scenario ${sc.label}: ${sc.title}") + results[sc.label] = runScenario(sc.label, prepare = sc.prep, out = ::log) + } + + // Summary vs A if measured + val a = results["A"] + if (a != null) { + log("[DEBUG_LOG] Summary deltas vs A (medians):") + fun deltaLine(name: String, r: ProfileResult) = "[DEBUG_LOG] ${name} - A: time=${r.timeNs - a.timeNs} ns, alloc=${r.allocBytes - a.allocBytes} B" + listOf("B","C","D","E","F","G").forEach { k -> + results[k]?.let { r -> log(deltaLine(k, r)) } + } + } + } finally { + saved.restore() + } + } +} + +// Minimal runBlocking bridge to avoid extra test deps here +private fun runTestBlocking(block: suspend () -> Unit) { + kotlinx.coroutines.runBlocking { block() } +} diff --git a/lynglib/src/jvmTest/kotlin/CallArgPipelineABTest.kt b/lynglib/src/jvmTest/kotlin/CallArgPipelineABTest.kt new file mode 100644 index 0000000..9cd1a01 --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/CallArgPipelineABTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2025 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.ObjInt +import java.io.File +import kotlin.system.measureNanoTime +import kotlin.test.Test + +class CallArgPipelineABTest { + + private fun outFile(): File = File("lynglib/build/call_ab_results.txt") + + private fun writeHeader(f: File) { + if (!f.parentFile.exists()) f.parentFile.mkdirs() + f.writeText("[DEBUG_LOG] Call/Arg pipeline A/B results\n") + } + + private fun appendLine(f: File, s: String) { + f.appendText(s + "\n") + } + + private suspend fun buildScriptForCalls(arity: Int, iters: Int): Script { + val argsDecl = (0 until arity).joinToString(",") { "a$it" } + val argsUse = (0 until arity).joinToString(" + ") { "a$it" }.ifEmpty { "0" } + val callArgs = (0 until arity).joinToString(",") { (it + 1).toString() } + val src = """ + var sum = 0 + fun f($argsDecl) { $argsUse } + for(i in 0..${iters - 1}) { + sum += f($callArgs) + } + sum + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun benchCallsOnce(arity: Int, iters: Int): Long { + val script = buildScriptForCalls(arity, iters) + val scope = Script.newScope() + var result: Obj? = null + val t = measureNanoTime { + result = script.execute(scope) + } + // Basic correctness check so JIT doesn’t elide + val expected = (0 until iters).fold(0L) { acc, _ -> + (acc + (1L + 2L + 3L + 4L + 5L + 6L + 7L + 8L).let { if (arity <= 8) it - (8 - arity) * 0L else it }) + } + // We only rely that it runs; avoid strict equals as function may compute differently for arities < 8 + if (result !is ObjInt) { + // ensure use to prevent DCE + println("[DEBUG_LOG] Result class=${result?.javaClass?.simpleName}") + } + return t + } + + private suspend fun benchOptionalCallShortCircuit(iters: Int): Long { + val src = """ + var side = 0 + fun inc() { side += 1 } + var o = null + for(i in 0..${iters - 1}) { + o?.foo(inc()) + } + side + """.trimIndent() + val script = Compiler.compile(Source("", src), Script.defaultImportManager) + val scope = Script.newScope() + var result: Obj? = null + val t = measureNanoTime { result = script.execute(scope) } + // Ensure short-circuit actually happened + require((result as? ObjInt)?.value == 0L) { "optional-call short-circuit failed; side=${(result as? ObjInt)?.value}" } + return t + } + + @Test + fun ab_call_pipeline() = runTestBlocking { + val f = outFile() + writeHeader(f) + + val savedArgBuilder = PerfFlags.ARG_BUILDER + val savedScopePool = PerfFlags.SCOPE_POOL + + try { + val iters = 50_000 + val aritiesBase = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8) + + // A/B for ARG_BUILDER (0..8) + PerfFlags.ARG_BUILDER = false + val offTimes = mutableListOf() + for (a in aritiesBase) offTimes += benchCallsOnce(a, iters) + PerfFlags.ARG_BUILDER = true + val onTimes = mutableListOf() + for (a in aritiesBase) onTimes += benchCallsOnce(a, iters) + + appendLine(f, "[DEBUG_LOG] ARG_BUILDER A/B (iters=$iters):") + aritiesBase.forEachIndexed { idx, a -> + appendLine(f, "[DEBUG_LOG] arity=$a OFF=${offTimes[idx]} ns, ON=${onTimes[idx]} ns, delta=${offTimes[idx] - onTimes[idx]} ns") + } + + // A/B for ARG_SMALL_ARITY_12 (9..12) + val aritiesExtended = listOf(9, 10, 11, 12) + val savedSmall = PerfFlags.ARG_SMALL_ARITY_12 + try { + PerfFlags.ARG_BUILDER = true // base builder on + PerfFlags.ARG_SMALL_ARITY_12 = false + val offExt = mutableListOf() + for (a in aritiesExtended) offExt += benchCallsOnce(a, iters) + PerfFlags.ARG_SMALL_ARITY_12 = true + val onExt = mutableListOf() + for (a in aritiesExtended) onExt += benchCallsOnce(a, iters) + appendLine(f, "[DEBUG_LOG] ARG_SMALL_ARITY_12 A/B (iters=$iters):") + aritiesExtended.forEachIndexed { idx, a -> + appendLine(f, "[DEBUG_LOG] arity=$a OFF=${offExt[idx]} ns, ON=${onExt[idx]} ns, delta=${offExt[idx] - onExt[idx]} ns") + } + } finally { + PerfFlags.ARG_SMALL_ARITY_12 = savedSmall + } + + // Optional call short-circuit sanity timing (does not A/B a flag currently; implementation short-circuits before args) + val tOpt = benchOptionalCallShortCircuit(100_000) + appendLine(f, "[DEBUG_LOG] Optional-call short-circuit sanity: ${tOpt} ns for 100k iterations (side-effect arg not evaluated).") + + // A/B for SCOPE_POOL + PerfFlags.SCOPE_POOL = false + val tPoolOff = benchCallsOnce(5, iters) + PerfFlags.SCOPE_POOL = true + val tPoolOn = benchCallsOnce(5, iters) + appendLine(f, "[DEBUG_LOG] SCOPE_POOL A/B (arity=5, iters=$iters): OFF=${tPoolOff} ns, ON=${tPoolOn} ns, delta=${tPoolOff - tPoolOn} ns") + + } finally { + PerfFlags.ARG_BUILDER = savedArgBuilder + PerfFlags.SCOPE_POOL = savedScopePool + } + } +} + +// Minimal runBlocking for common jvmTest without depending on kotlinx.coroutines test artifacts here +private fun runTestBlocking(block: suspend () -> Unit) { + kotlinx.coroutines.runBlocking { block() } +} diff --git a/lynglib/src/jvmTest/kotlin/IndexPicABTest.kt b/lynglib/src/jvmTest/kotlin/IndexPicABTest.kt new file mode 100644 index 0000000..4b42c44 --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/IndexPicABTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2025 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.ObjInt +import java.io.File +import kotlin.system.measureNanoTime +import kotlin.test.Test + +class IndexPicABTest { + + private fun outFile(): File = File("lynglib/build/index_pic_ab_results.txt") + + private fun writeHeader(f: File) { + if (!f.parentFile.exists()) f.parentFile.mkdirs() + f.writeText("[DEBUG_LOG] Index PIC A/B results\n") + } + + private fun appendLine(f: File, s: String) { f.appendText(s + "\n") } + + private suspend fun buildStringIndexScript(len: Int, iters: Int): Script { + // Build a long string and index it by cycling positions + val content = (0 until len).joinToString("") { i -> + val ch = 'a' + (i % 26) + ch.toString() + } + val src = """ + val s = "$content" + var acc = 0 + for(i in 0..${iters - 1}) { + val j = i % ${len} + // Compare to a 1-char string to avoid needing Char.toInt(); still exercises indexing path + if (s[j] == "a") { acc += 1 } else { acc += 0 } + } + acc + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun buildMapIndexScript(keys: Int, iters: Int): Script { + // Build a map of ("kX" -> X) and repeatedly access by key cycling + val entries = (0 until keys).joinToString(", ") { i -> "\"k$i\" => $i" } + val src = """ + // Build via Map(entry1, entry2, ...), not a list literal + val m = Map($entries) + var acc = 0 + for(i in 0..${iters - 1}) { + val k = "k" + (i % ${keys}) + acc += (m[k] ?: 0) + } + acc + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun runOnce(script: Script): Long { + val scope = Script.newScope() + var result: Obj? = null + val t = measureNanoTime { result = script.execute(scope) } + if (result !is ObjInt) println("[DEBUG_LOG] result=${result?.javaClass?.simpleName}") + return t + } + + @Test + fun ab_index_pic_and_size() = runTestBlocking { + val f = outFile() + writeHeader(f) + + val savedIndexPic = PerfFlags.INDEX_PIC + val savedIndexSize4 = PerfFlags.INDEX_PIC_SIZE_4 + val savedCounters = PerfFlags.PIC_DEBUG_COUNTERS + + try { + val iters = 300_000 + val sLen = 512 + val mapKeys = 256 + val sScript = buildStringIndexScript(sLen, iters) + val mScript = buildMapIndexScript(mapKeys, iters) + + fun header(which: String) { appendLine(f, "[DEBUG_LOG] A/B on $which (iters=$iters)") } + + // Baseline OFF + PerfFlags.PIC_DEBUG_COUNTERS = true + PerfStats.resetAll() + PerfFlags.INDEX_PIC = false + PerfFlags.INDEX_PIC_SIZE_4 = false + header("String[Int], INDEX_PIC=OFF") + val tSOff = runOnce(sScript) + header("Map[String], INDEX_PIC=OFF") + val tMOff = runOnce(mScript) + appendLine(f, "[DEBUG_LOG] OFF counters: indexHit=${PerfStats.indexPicHit} indexMiss=${PerfStats.indexPicMiss}") + + // PIC ON, size 2 + PerfStats.resetAll() + PerfFlags.INDEX_PIC = true + PerfFlags.INDEX_PIC_SIZE_4 = false + val tSOn2 = runOnce(sScript) + val tMOn2 = runOnce(mScript) + appendLine(f, "[DEBUG_LOG] ON size=2 counters: indexHit=${PerfStats.indexPicHit} indexMiss=${PerfStats.indexPicMiss}") + + // PIC ON, size 4 + PerfStats.resetAll() + PerfFlags.INDEX_PIC = true + PerfFlags.INDEX_PIC_SIZE_4 = true + val tSOn4 = runOnce(sScript) + val tMOn4 = runOnce(mScript) + appendLine(f, "[DEBUG_LOG] ON size=4 counters: indexHit=${PerfStats.indexPicHit} indexMiss=${PerfStats.indexPicMiss}") + + // Report + appendLine(f, "[DEBUG_LOG] String[Int] OFF=${tSOff} ns, ON(2)=${tSOn2} ns, ON(4)=${tSOn4} ns") + appendLine(f, "[DEBUG_LOG] Map[String] OFF=${tMOff} ns, ON(2)=${tMOn2} ns, ON(4)=${tMOn4} ns") + } finally { + PerfFlags.INDEX_PIC = savedIndexPic + PerfFlags.INDEX_PIC_SIZE_4 = savedIndexSize4 + PerfFlags.PIC_DEBUG_COUNTERS = savedCounters + } + } +} + +private fun runTestBlocking(block: suspend () -> Unit) { + kotlinx.coroutines.runBlocking { block() } +} diff --git a/lynglib/src/jvmTest/kotlin/IndexWritePathABTest.kt b/lynglib/src/jvmTest/kotlin/IndexWritePathABTest.kt new file mode 100644 index 0000000..61b50a7 --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/IndexWritePathABTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2025 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.ObjInt +import java.io.File +import kotlin.system.measureNanoTime +import kotlin.test.Test + +/** + * A/B micro-benchmark for index WRITE paths (Map[String] put, List[Int] set). + * Measures OFF vs ON for INDEX_PIC and then size 2 vs 4 (INDEX_PIC_SIZE_4). + * Produces [DEBUG_LOG] output in lynglib/build/index_write_ab_results.txt + */ +class IndexWritePathABTest { + + private fun outFile(): File = File("lynglib/build/index_write_ab_results.txt") + + private fun writeHeader(f: File) { + if (!f.parentFile.exists()) f.parentFile.mkdirs() + f.writeText("[DEBUG_LOG] Index WRITE PIC A/B results\n") + } + + private fun appendLine(f: File, s: String) { f.appendText(s + "\n") } + + private suspend fun buildMapWriteScript(keys: Int, iters: Int): Script { + // Construct map with keys k0..k{keys-1} and then perform writes in a tight loop + val initEntries = (0 until keys).joinToString(", ") { i -> "\"k$i\" => $i" } + val src = """ + var acc = 0 + val m = Map($initEntries) + for(i in 0..${iters - 1}) { + val k = "k" + (i % $keys) + m[k] = i + acc += (m[k] ?: 0) + } + acc + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun buildListWriteScript(len: Int, iters: Int): Script { + val initList = (0 until len).joinToString(", ") { i -> i.toString() } + val src = """ + var acc = 0 + val a = [$initList] + for(i in 0..${iters - 1}) { + val j = i % $len + a[j] = i + acc += a[j] + } + acc + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun runOnce(script: Script): Long { + val scope = Script.newScope() + var result: Obj? = null + val t = measureNanoTime { result = script.execute(scope) } + if (result !is ObjInt) println("[DEBUG_LOG] result=${result?.javaClass?.simpleName}") + return t + } + + @Test + fun ab_index_write_paths() = runTestBlocking { + val f = outFile() + writeHeader(f) + + val savedIndexPic = PerfFlags.INDEX_PIC + val savedIndexSize4 = PerfFlags.INDEX_PIC_SIZE_4 + val savedCounters = PerfFlags.PIC_DEBUG_COUNTERS + + try { + val iters = 250_000 + val mapKeys = 256 + val listLen = 1024 + val mScript = buildMapWriteScript(mapKeys, iters) + val lScript = buildListWriteScript(listLen, iters) + + fun header(which: String) { appendLine(f, "[DEBUG_LOG] A/B on $which (iters=$iters)") } + + // Baseline OFF + PerfFlags.PIC_DEBUG_COUNTERS = true + PerfStats.resetAll() + PerfFlags.INDEX_PIC = false + PerfFlags.INDEX_PIC_SIZE_4 = false + header("Map[String] write, INDEX_PIC=OFF") + val tMOff = runOnce(mScript) + header("List[Int] write, INDEX_PIC=OFF") + val tLOff = runOnce(lScript) + appendLine(f, "[DEBUG_LOG] OFF counters: indexHit=${PerfStats.indexPicHit} indexMiss=${PerfStats.indexPicMiss}") + + // PIC ON, size 2 + PerfStats.resetAll() + PerfFlags.INDEX_PIC = true + PerfFlags.INDEX_PIC_SIZE_4 = false + val tMOn2 = runOnce(mScript) + val tLOn2 = runOnce(lScript) + appendLine(f, "[DEBUG_LOG] ON size=2 counters: indexHit=${PerfStats.indexPicHit} indexMiss=${PerfStats.indexPicMiss}") + + // PIC ON, size 4 + PerfStats.resetAll() + PerfFlags.INDEX_PIC = true + PerfFlags.INDEX_PIC_SIZE_4 = true + val tMOn4 = runOnce(mScript) + val tLOn4 = runOnce(lScript) + appendLine(f, "[DEBUG_LOG] ON size=4 counters: indexHit=${PerfStats.indexPicHit} indexMiss=${PerfStats.indexPicMiss}") + + // Report + appendLine(f, "[DEBUG_LOG] Map[String] WRITE OFF=$tMOff ns, ON(2)=$tMOn2 ns, ON(4)=$tMOn4 ns") + appendLine(f, "[DEBUG_LOG] List[Int] WRITE OFF=$tLOff ns, ON(2)=$tLOn2 ns, ON(4)=$tLOn4 ns") + } finally { + PerfFlags.INDEX_PIC = savedIndexPic + PerfFlags.INDEX_PIC_SIZE_4 = savedIndexSize4 + PerfFlags.PIC_DEBUG_COUNTERS = savedCounters + } + } +} + +private fun runTestBlocking(block: suspend () -> Unit) { + kotlinx.coroutines.runBlocking { block() } +} diff --git a/lynglib/src/jvmTest/kotlin/PerfProfilesTest.kt b/lynglib/src/jvmTest/kotlin/PerfProfilesTest.kt new file mode 100644 index 0000000..bf44b39 --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/PerfProfilesTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2025 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PerfProfilesTest { + + @Test + fun apply_and_restore_presets() { + val before = PerfProfiles.snapshot() + + try { + // BENCH preset expectations + val snapAfterBench = PerfProfiles.apply(PerfProfiles.Preset.BENCH) + // Expect some key flags ON for benches + assertTrue(PerfFlags.ARG_BUILDER) + assertTrue(PerfFlags.SCOPE_POOL) + assertTrue(PerfFlags.FIELD_PIC) + assertTrue(PerfFlags.METHOD_PIC) + assertTrue(PerfFlags.INDEX_PIC) + assertTrue(PerfFlags.INDEX_PIC_SIZE_4) + assertTrue(PerfFlags.PRIMITIVE_FASTOPS) + assertTrue(PerfFlags.RVAL_FASTPATH) + // Restore via snapshot returned by apply + PerfProfiles.restore(snapAfterBench) + + // BOOKS preset expectations + val snapAfterBooks = PerfProfiles.apply(PerfProfiles.Preset.BOOKS) + // Expect simpler paths enabled/disabled accordingly + assertEquals(false, PerfFlags.ARG_BUILDER) + assertEquals(false, PerfFlags.SCOPE_POOL) + assertEquals(false, PerfFlags.FIELD_PIC) + assertEquals(false, PerfFlags.METHOD_PIC) + assertEquals(false, PerfFlags.INDEX_PIC) + assertEquals(false, PerfFlags.INDEX_PIC_SIZE_4) + assertEquals(false, PerfFlags.PRIMITIVE_FASTOPS) + assertEquals(false, PerfFlags.RVAL_FASTPATH) + // Restore via snapshot returned by apply + PerfProfiles.restore(snapAfterBooks) + + // BASELINE preset should match PerfDefaults + val snapAfterBaseline = PerfProfiles.apply(PerfProfiles.Preset.BASELINE) + assertEquals(PerfDefaults.ARG_BUILDER, PerfFlags.ARG_BUILDER) + assertEquals(PerfDefaults.SCOPE_POOL, PerfFlags.SCOPE_POOL) + assertEquals(PerfDefaults.FIELD_PIC, PerfFlags.FIELD_PIC) + assertEquals(PerfDefaults.METHOD_PIC, PerfFlags.METHOD_PIC) + assertEquals(PerfDefaults.INDEX_PIC_SIZE_4, PerfFlags.INDEX_PIC_SIZE_4) + assertEquals(PerfDefaults.PRIMITIVE_FASTOPS, PerfFlags.PRIMITIVE_FASTOPS) + assertEquals(PerfDefaults.RVAL_FASTPATH, PerfFlags.RVAL_FASTPATH) + // Restore baseline snapshot + PerfProfiles.restore(snapAfterBaseline) + + } finally { + // Finally, restore very original snapshot + PerfProfiles.restore(before) + } + } +} diff --git a/lynglib/src/jvmTest/kotlin/PicAdaptiveABTest.kt b/lynglib/src/jvmTest/kotlin/PicAdaptiveABTest.kt new file mode 100644 index 0000000..c00ca22 --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/PicAdaptiveABTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2025 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.ObjInt +import java.io.File +import kotlin.system.measureNanoTime +import kotlin.test.Test + +class PicAdaptiveABTest { + + private fun outFile(): File = File("lynglib/build/pic_adaptive_ab_results.txt") + + private fun writeHeader(f: File) { + if (!f.parentFile.exists()) f.parentFile.mkdirs() + f.writeText("[DEBUG_LOG] PIC Adaptive 2→4 A/B results\n") + } + + private fun appendLine(f: File, s: String) { f.appendText(s + "\n") } + + private suspend fun buildScriptForMethodShapes(shapes: Int, iters: Int): Script { + // Define N classes C0..C{shapes-1} each with method f() { 1 } + val classes = (0 until shapes).joinToString("\n") { i -> + "class C$i { fun f() { $i } var x = 0 }" + } + val inits = (0 until shapes).joinToString(", ") { i -> "C$i()" } + val calls = buildString { + append("var s = 0\n") + append("val a = [${inits}]\n") + append("for(i in 0..${iters - 1}) {\n") + append(" val o = a[i % ${shapes}]\n") + append(" s += o.f()\n") + append("}\n") + append("s\n") + } + val src = classes + "\n" + calls + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun buildScriptForFieldShapes(shapes: Int, iters: Int): Script { + // Each class has a mutable field x initialized to 0; read and write it + val classes = (0 until shapes).joinToString("\n") { i -> + "class F$i { var x = 0 }" + } + val inits = (0 until shapes).joinToString(", ") { i -> "F$i()" } + val body = buildString { + append("var s = 0\n") + append("val a = [${inits}]\n") + append("for(i in 0..${iters - 1}) {\n") + append(" val o = a[i % ${shapes}]\n") + append(" s += o.x\n") + append(" o.x = o.x + 1\n") + append("}\n") + append("s\n") + } + val src = classes + "\n" + body + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun runOnce(script: Script): Long { + val scope = Script.newScope() + var result: Obj? = null + val t = measureNanoTime { result = script.execute(scope) } + if (result !is ObjInt) println("[DEBUG_LOG] result=${result?.javaClass?.simpleName}") + return t + } + + @Test + fun ab_adaptive_pic() = runTestBlocking { + val f = outFile() + writeHeader(f) + + val savedAdaptive = PerfFlags.PIC_ADAPTIVE_2_TO_4 + val savedCounters = PerfFlags.PIC_DEBUG_COUNTERS + val savedFieldPic = PerfFlags.FIELD_PIC + val savedMethodPic = PerfFlags.METHOD_PIC + val savedFieldPicSize4 = PerfFlags.FIELD_PIC_SIZE_4 + val savedMethodPicSize4 = PerfFlags.METHOD_PIC_SIZE_4 + + try { + // Ensure baseline PICs are enabled and fixed-size flags OFF to isolate adaptivity + PerfFlags.FIELD_PIC = true + PerfFlags.METHOD_PIC = true + PerfFlags.FIELD_PIC_SIZE_4 = false + PerfFlags.METHOD_PIC_SIZE_4 = false + + // Prepare workloads with 3 and 4 receiver shapes + val iters = 200_000 + val meth3 = buildScriptForMethodShapes(3, iters) + val meth4 = buildScriptForMethodShapes(4, iters) + val fld3 = buildScriptForFieldShapes(3, iters) + val fld4 = buildScriptForFieldShapes(4, iters) + + fun header(which: String) { + appendLine(f, "[DEBUG_LOG] A/B Adaptive PIC on $which (iters=$iters)") + } + + // OFF pass + PerfFlags.PIC_DEBUG_COUNTERS = true + PerfStats.resetAll() + PerfFlags.PIC_ADAPTIVE_2_TO_4 = false + header("methods-3") + val tM3Off = runOnce(meth3) + header("methods-4") + val tM4Off = runOnce(meth4) + header("fields-3") + val tF3Off = runOnce(fld3) + header("fields-4") + val tF4Off = runOnce(fld4) + appendLine(f, "[DEBUG_LOG] OFF counters: methodHit=${PerfStats.methodPicHit} methodMiss=${PerfStats.methodPicMiss} fieldHit=${PerfStats.fieldPicHit} fieldMiss=${PerfStats.fieldPicMiss}") + + // ON pass + PerfStats.resetAll() + PerfFlags.PIC_ADAPTIVE_2_TO_4 = true + val tM3On = runOnce(meth3) + val tM4On = runOnce(meth4) + val tF3On = runOnce(fld3) + val tF4On = runOnce(fld4) + appendLine(f, "[DEBUG_LOG] ON counters: methodHit=${PerfStats.methodPicHit} methodMiss=${PerfStats.methodPicMiss} fieldHit=${PerfStats.fieldPicHit} fieldMiss=${PerfStats.fieldPicMiss}") + + // Report + appendLine(f, "[DEBUG_LOG] methods-3 OFF=${tM3Off} ns, ON=${tM3On} ns, delta=${tM3Off - tM3On} ns") + appendLine(f, "[DEBUG_LOG] methods-4 OFF=${tM4Off} ns, ON=${tM4On} ns, delta=${tM4Off - tM4On} ns") + appendLine(f, "[DEBUG_LOG] fields-3 OFF=${tF3Off} ns, ON=${tF3On} ns, delta=${tF3Off - tF3On} ns") + appendLine(f, "[DEBUG_LOG] fields-4 OFF=${tF4Off} ns, ON=${tF4On} ns, delta=${tF4Off - tF4On} ns") + } finally { + PerfFlags.PIC_ADAPTIVE_2_TO_4 = savedAdaptive + PerfFlags.PIC_DEBUG_COUNTERS = savedCounters + PerfFlags.FIELD_PIC = savedFieldPic + PerfFlags.METHOD_PIC = savedMethodPic + PerfFlags.FIELD_PIC_SIZE_4 = savedFieldPicSize4 + PerfFlags.METHOD_PIC_SIZE_4 = savedMethodPicSize4 + } + } +} + +private fun runTestBlocking(block: suspend () -> Unit) { + kotlinx.coroutines.runBlocking { block() } +} diff --git a/lynglib/src/jvmTest/kotlin/PicMethodsOnlyAdaptiveABTest.kt b/lynglib/src/jvmTest/kotlin/PicMethodsOnlyAdaptiveABTest.kt new file mode 100644 index 0000000..da36e4e --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/PicMethodsOnlyAdaptiveABTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025 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.ObjInt +import java.io.File +import kotlin.system.measureNanoTime +import kotlin.test.Test + +/** + * A/B micro-benchmark to compare methods-only adaptive PIC OFF vs ON. + * Ensures fixed PIC sizes (2-entry) and only toggles PIC_ADAPTIVE_METHODS_ONLY. + * Writes a summary to lynglib/build/pic_methods_only_adaptive_ab_results.txt + */ +class PicMethodsOnlyAdaptiveABTest { + + private fun outFile(): File = File("lynglib/build/pic_methods_only_adaptive_ab_results.txt") + + private fun writeHeader(f: File) { + if (!f.parentFile.exists()) f.parentFile.mkdirs() + f.writeText("[DEBUG_LOG] PIC Adaptive (methods-only) 2→4 A/B results\n") + } + + private fun appendLine(f: File, s: String) { f.appendText(s + "\n") } + + private suspend fun buildScriptForMethodShapes(shapes: Int, iters: Int): Script { + // Define N classes C0..C{shapes-1} each with method f() { i } + val classes = (0 until shapes).joinToString("\n") { i -> + "class MC$i { fun f() { $i } }" + } + val inits = (0 until shapes).joinToString(", ") { i -> "MC$i()" } + val body = buildString { + append("var s = 0\n") + append("val a = [${inits}]\n") + append("for(i in 0..${iters - 1}) {\n") + append(" val o = a[i % ${shapes}]\n") + append(" s += o.f()\n") + append("}\n") + append("s\n") + } + val src = classes + "\n" + body + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun runOnce(script: Script): Long { + val scope = Script.newScope() + var result: Obj? = null + val t = measureNanoTime { result = script.execute(scope) } + if (result !is ObjInt) println("[DEBUG_LOG] result=${result?.javaClass?.simpleName}") + return t + } + + @Test + fun ab_methods_only_adaptive_pic() = runTestBlocking { + val f = outFile() + writeHeader(f) + + // Save flags + val savedAdaptive2To4 = PerfFlags.PIC_ADAPTIVE_2_TO_4 + val savedAdaptiveMethodsOnly = PerfFlags.PIC_ADAPTIVE_METHODS_ONLY + val savedFieldPic = PerfFlags.FIELD_PIC + val savedMethodPic = PerfFlags.METHOD_PIC + val savedFieldSize4 = PerfFlags.FIELD_PIC_SIZE_4 + val savedMethodSize4 = PerfFlags.METHOD_PIC_SIZE_4 + val savedCounters = PerfFlags.PIC_DEBUG_COUNTERS + + try { + // Fixed-size 2-entry PICs, enable PICs, disable global adaptivity + PerfFlags.FIELD_PIC = true + PerfFlags.METHOD_PIC = true + PerfFlags.FIELD_PIC_SIZE_4 = false + PerfFlags.METHOD_PIC_SIZE_4 = false + PerfFlags.PIC_ADAPTIVE_2_TO_4 = false + + val iters = 200_000 + val meth3 = buildScriptForMethodShapes(3, iters) + val meth4 = buildScriptForMethodShapes(4, iters) + + fun header(which: String) { appendLine(f, "[DEBUG_LOG] A/B Methods-only adaptive on $which (iters=$iters)") } + + // OFF pass + PerfFlags.PIC_DEBUG_COUNTERS = true + PerfStats.resetAll() + PerfFlags.PIC_ADAPTIVE_METHODS_ONLY = false + header("methods-3") + val tM3Off = runOnce(meth3) + header("methods-4") + val tM4Off = runOnce(meth4) + appendLine(f, "[DEBUG_LOG] OFF counters: methodHit=${PerfStats.methodPicHit} methodMiss=${PerfStats.methodPicMiss}") + + // ON pass + PerfStats.resetAll() + PerfFlags.PIC_ADAPTIVE_METHODS_ONLY = true + val tM3On = runOnce(meth3) + val tM4On = runOnce(meth4) + appendLine(f, "[DEBUG_LOG] ON counters: methodHit=${PerfStats.methodPicHit} methodMiss=${PerfStats.methodPicMiss}") + + // Report + appendLine(f, "[DEBUG_LOG] methods-3 OFF=${tM3Off} ns, ON=${tM3On} ns, delta=${tM3Off - tM3On} ns") + appendLine(f, "[DEBUG_LOG] methods-4 OFF=${tM4Off} ns, ON=${tM4On} ns, delta=${tM4Off - tM4On} ns") + } finally { + // Restore + PerfFlags.PIC_ADAPTIVE_2_TO_4 = savedAdaptive2To4 + PerfFlags.PIC_ADAPTIVE_METHODS_ONLY = savedAdaptiveMethodsOnly + PerfFlags.FIELD_PIC = savedFieldPic + PerfFlags.METHOD_PIC = savedMethodPic + PerfFlags.FIELD_PIC_SIZE_4 = savedFieldSize4 + PerfFlags.METHOD_PIC_SIZE_4 = savedMethodSize4 + PerfFlags.PIC_DEBUG_COUNTERS = savedCounters + } + } +} + +private fun runTestBlocking(block: suspend () -> Unit) { + kotlinx.coroutines.runBlocking { block() } +} diff --git a/lynglib/src/jvmTest/kotlin/PrimitiveFastOpsABTest.kt b/lynglib/src/jvmTest/kotlin/PrimitiveFastOpsABTest.kt new file mode 100644 index 0000000..aa363c5 --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/PrimitiveFastOpsABTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2025 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. + * + */ + +/* + * A/B micro-benchmark to compare PerfFlags.PRIMITIVE_FASTOPS OFF vs ON. + * JVM-only quick check using simple arithmetic/logic loops. + */ +package net.sergeych.lyng + +import java.io.File +import kotlin.system.measureNanoTime +import kotlin.test.Test + +class PrimitiveFastOpsABTest { + + private fun outFile(): File = File("lynglib/build/primitive_ab_results.txt") + + private fun writeHeader(f: File) { + if (!f.parentFile.exists()) f.parentFile.mkdirs() + f.writeText("[DEBUG_LOG] Primitive FastOps A/B results\n") + } + + private fun appendLine(f: File, s: String) { + f.appendText(s + "\n") + } + + private fun benchIntArithmeticIters(iters: Int): Long { + var acc = 0L + val t = measureNanoTime { + var a = 1L + var b = 2L + var c = 3L + repeat(iters) { + // mimic mix of +, -, *, /, %, shifts and comparisons + a = (a + b) xor c + b = (b * 3L + a) and 0x7FFF_FFFFL + if ((b and 1L) == 0L) c = c + 1L else c = c - 1L + acc = acc + (a and b) + (c or a) + } + } + // use acc to prevent DCE + if (acc == 42L) println("[DEBUG_LOG] impossible") + return t + } + + private fun benchBoolLogicIters(iters: Int): Long { + var acc = 0 + val t = measureNanoTime { + var a = true + var b = false + repeat(iters) { + a = a || b + b = !b && a + if (a == b) acc++ else acc-- + } + } + if (acc == Int.MIN_VALUE) println("[DEBUG_LOG] impossible2") + return t + } + + @Test + fun ab_compare_primitive_fastops() { + // Save current settings + val savedFast = PerfFlags.PRIMITIVE_FASTOPS + val savedCounters = PerfFlags.PIC_DEBUG_COUNTERS + val f = outFile() + writeHeader(f) + + try { + val iters = 500_000 + + // OFF pass + PerfFlags.PIC_DEBUG_COUNTERS = true + PerfStats.resetAll() + PerfFlags.PRIMITIVE_FASTOPS = false + val tArithOff = benchIntArithmeticIters(iters) + val tLogicOff = benchBoolLogicIters(iters) + + // ON pass + PerfStats.resetAll() + PerfFlags.PRIMITIVE_FASTOPS = true + val tArithOn = benchIntArithmeticIters(iters) + val tLogicOn = benchBoolLogicIters(iters) + + println("[DEBUG_LOG] A/B PrimitiveFastOps (iters=$iters):") + println("[DEBUG_LOG] Arithmetic OFF: ${'$'}tArithOff ns, ON: ${'$'}tArithOn ns, delta: ${'$'}{tArithOff - tArithOn} ns") + println("[DEBUG_LOG] Bool logic OFF: ${'$'}tLogicOff ns, ON: ${'$'}tLogicOn ns, delta: ${'$'}{tLogicOff - tLogicOn} ns") + + appendLine(f, "[DEBUG_LOG] A/B PrimitiveFastOps (iters=$iters):") + appendLine(f, "[DEBUG_LOG] Arithmetic OFF: ${'$'}tArithOff ns, ON: ${'$'}tArithOn ns, delta: ${'$'}{tArithOff - tArithOn} ns") + appendLine(f, "[DEBUG_LOG] Bool logic OFF: ${'$'}tLogicOff ns, ON: ${'$'}tLogicOn ns, delta: ${'$'}{tLogicOff - tLogicOn} ns") + } finally { + // restore + PerfFlags.PRIMITIVE_FASTOPS = savedFast + PerfFlags.PIC_DEBUG_COUNTERS = savedCounters + } + } +} diff --git a/lynglib/src/jvmTest/kotlin/RangeIterationBenchmarkTest.kt b/lynglib/src/jvmTest/kotlin/RangeIterationBenchmarkTest.kt new file mode 100644 index 0000000..5fe72c6 --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/RangeIterationBenchmarkTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright 2025 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.ObjInt +import java.io.File +import kotlin.system.measureNanoTime +import kotlin.test.Test + +/** + * Baseline range iteration benchmark. It measures for-loops over integer ranges under + * current implementation and records timings. When RANGE_FAST_ITER is implemented, + * this test will also serve for OFF vs ON A/B. + */ +class RangeIterationBenchmarkTest { + + private fun outFile(): File = File("lynglib/build/range_iter_bench.txt") + + private fun writeHeader(f: File) { + if (!f.parentFile.exists()) f.parentFile.mkdirs() + f.writeText("[DEBUG_LOG] Range iteration benchmark results\n") + } + + private fun appendLine(f: File, s: String) { f.appendText(s + "\n") } + + private suspend fun buildSumScriptInclusive(n: Int, iters: Int): Script { + // Sum 0..n repeatedly to stress iteration + val src = """ + var total = 0 + for (k in 0..${iters - 1}) { + var s = 0 + for (i in 0..$n) { s += i } + total += s + } + total + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun buildSumScriptExclusive(n: Int, iters: Int): Script { + val src = """ + var total = 0 + for (k in 0..${iters - 1}) { + var s = 0 + for (i in 0..<$n) { s += i } + total += s + } + total + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun buildSumScriptReversed(n: Int, iters: Int): Script { + val src = """ + var total = 0 + for (k in 0..${iters - 1}) { + var s = 0 + // reversed-like loop using countdown range (n..0) + for (i in $n..0) { s += i } + total += s + } + total + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun buildSumScriptNegative(n: Int, iters: Int): Script { + // Sum -n..n repeatedly + val src = """ + var total = 0 + for (k in 0..${iters - 1}) { + var s = 0 + for (i in -$n..$n) { s += (i < 0 ? -i : i) } + total += s + } + total + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun buildSumScriptEmpty(iters: Int): Script { + // Empty range 1..0 should not iterate + val src = """ + var total = 0 + for (k in 0..${iters - 1}) { + var s = 0 + for (i in 1..0) { s += 1 } + total += s + } + total + """.trimIndent() + return Compiler.compile(Source("", src), Script.defaultImportManager) + } + + private suspend fun runOnce(script: Script): Long { + val scope = Script.newScope() + var result: Obj? = null + val t = measureNanoTime { result = script.execute(scope) } + if (result !is ObjInt) println("[DEBUG_LOG] result=${result?.javaClass?.simpleName}") + return t + } + + @Test + fun bench_range_iteration_baseline() = runTestBlocking { + val f = outFile() + writeHeader(f) + + val savedFlag = PerfFlags.RANGE_FAST_ITER + try { + val n = 1000 + val iters = 500 + + // Baseline with current flag (OFF by default) + PerfFlags.RANGE_FAST_ITER = false + val sIncOff = buildSumScriptInclusive(n, iters) + val tIncOff = runOnce(sIncOff) + val sExcOff = buildSumScriptExclusive(n, iters) + val tExcOff = runOnce(sExcOff) + appendLine(f, "[DEBUG_LOG] OFF inclusive=${tIncOff} ns, exclusive=${tExcOff} ns") + + // Also record ON times + PerfFlags.RANGE_FAST_ITER = true + val sIncOn = buildSumScriptInclusive(n, iters) + val tIncOn = runOnce(sIncOn) + val sExcOn = buildSumScriptExclusive(n, iters) + val tExcOn = runOnce(sExcOn) + appendLine(f, "[DEBUG_LOG] ON inclusive=${tIncOn} ns, exclusive=${tExcOn} ns") + + // Additional scenarios: reversed, negative, empty + PerfFlags.RANGE_FAST_ITER = false + val sRevOff = buildSumScriptReversed(n, iters) + val tRevOff = runOnce(sRevOff) + val sNegOff = buildSumScriptNegative(n, iters) + val tNegOff = runOnce(sNegOff) + val sEmptyOff = buildSumScriptEmpty(iters) + val tEmptyOff = runOnce(sEmptyOff) + appendLine(f, "[DEBUG_LOG] OFF reversed=${tRevOff} ns, negative=${tNegOff} ns, empty=${tEmptyOff} ns") + + PerfFlags.RANGE_FAST_ITER = true + val sRevOn = buildSumScriptReversed(n, iters) + val tRevOn = runOnce(sRevOn) + val sNegOn = buildSumScriptNegative(n, iters) + val tNegOn = runOnce(sNegOn) + val sEmptyOn = buildSumScriptEmpty(iters) + val tEmptyOn = runOnce(sEmptyOn) + appendLine(f, "[DEBUG_LOG] ON reversed=${tRevOn} ns, negative=${tNegOn} ns, empty=${tEmptyOn} ns") + } finally { + PerfFlags.RANGE_FAST_ITER = savedFlag + } + } +} + +private fun runTestBlocking(block: suspend () -> Unit) { + kotlinx.coroutines.runBlocking { block() } +}