From 0add0ab54cc6d34ed8c4e60eb3d6d5fbce1ea34d Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 22 Dec 2025 06:20:24 +0100 Subject: [PATCH] fix #95 destructuring assignment and splats --- docs/List.md | 28 ++++++++ docs/declaring_arguments.md | 8 +++ docs/tutorial.md | 35 +++++++++ .../kotlin/net/sergeych/lyng/Compiler.kt | 69 +++++++++++++++--- .../kotlin/net/sergeych/lyng/Scope.kt | 4 ++ .../kotlin/net/sergeych/lyng/obj/ObjRef.kt | 71 ++++++++++++++++++- lynglib/src/commonTest/kotlin/ScriptTest.kt | 49 ++++++++++--- 7 files changed, 244 insertions(+), 20 deletions(-) diff --git a/docs/List.md b/docs/List.md index d7ea8ba..0631ffd 100644 --- a/docs/List.md +++ b/docs/List.md @@ -92,6 +92,34 @@ Open end ranges remove head and tail elements: assert( [1, 2, 3] !== [1, 2, 3]) >>> void +## Destructuring + +Lists can be used as L-values for destructuring assignments. This allows you to unpack list elements into multiple variables. + +### Basic Destructuring +```lyng +val [a, b, c] = [1, 2, 3] +``` + +### With Splats (Variadic) +A single ellipsis `...` can be used to capture remaining elements into a list. It can be placed at the beginning, middle, or end of the pattern. +```lyng +val [head, rest...] = [1, 2, 3] // head=1, rest=[2, 3] +val [first, middle..., last] = [1, 2, 3, 4, 5] // first=1, middle=[2, 3, 4], last=5 +``` + +### Nested Patterns +Destructuring patterns can be nested to unpack multi-dimensional lists. +```lyng +val [a, [b, c...], d] = [1, [2, 3, 4], 5] +``` + +### Reassignment +Destructuring can also be used to reassign existing variables: +```lyng +[x, y] = [y, x] // Swap values +``` + ## In-place sort List could be sorted in place, just like [Collection] provide sorted copies, in a very like way: diff --git a/docs/declaring_arguments.md b/docs/declaring_arguments.md index 7930cd0..2995c00 100644 --- a/docs/declaring_arguments.md +++ b/docs/declaring_arguments.md @@ -75,6 +75,13 @@ destructuring arrays when calling functions and lambdas: getFirstAndLast( ...(1..10) ) // see "splats" section below >>> [1,10] +Note that array destructuring can also be used in assignments: + + val [first, middle..., last] = [1, 2, 3, 4, 5] + [x, y] = [y, x] // Swap + +See [tutorial] and [List] documentation for more details on destructuring assignments. + # Splats Ellipsis allows to convert argument lists to lists. The inversa algorithm that converts [List], @@ -155,3 +162,4 @@ If a call is immediately followed by a block `{ ... }`, it is treated as an extr [tutorial]: tutorial.md +[List]: List.md diff --git a/docs/tutorial.md b/docs/tutorial.md index 4d5974c..7eb6130 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -107,6 +107,41 @@ Assignemnt is an expression that changes its lvalue and return assigned value: >>> 11 >>> 6 +### Destructuring assignments + +Lyng supports destructuring assignments for lists. This allows you to unpack list elements into multiple variables at once: + + val [a, b, c] = [1, 2, 3] + assertEquals(1, a) + assertEquals(2, b) + assertEquals(3, c) + +It also supports *splats* (ellipsis) to capture multiple elements into a list: + + val [head, rest...] = [1, 2, 3] + assertEquals(1, head) + assertEquals([2, 3], rest) + + val [first, middle..., last] = [1, 2, 3, 4, 5] + assertEquals(1, first) + assertEquals([2, 3, 4], middle) + assertEquals(5, last) + +Destructuring can be nested: + + val [x, [y, z...]] = [1, [2, 3, 4]] + assertEquals(1, x) + assertEquals(2, y) + assertEquals([3, 4], z) + +And it can be used for reassigning existing variables, for example, to swap values: + + var x = 5 + var y = 10 + [x, y] = [y, x] + assertEquals(10, x) + assertEquals(5, y) + As the assignment itself is an expression, you can use it in strange ways. Just remember to use parentheses as assignment operation insofar is left-associated and will not allow chained assignments (we might fix it later). Use parentheses insofar: diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index d7c860f..4166b8d 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -674,12 +674,19 @@ class Compiler( Token.Type.RBRACKET -> return entries Token.Type.ELLIPSIS -> { parseExpressionLevel()?.let { entries += ListEntry.Spread(it) } + ?: throw ScriptError(t.pos, "spread element must have an expression") } else -> { cc.previous() - parseExpressionLevel()?.let { entries += ListEntry.Element(it) } - ?: throw ScriptError(t.pos, "invalid list literal: expecting expression") + parseExpressionLevel()?.let { expr -> + if (cc.current().type == Token.Type.ELLIPSIS) { + cc.next() + entries += ListEntry.Spread(expr) + } else { + entries += ListEntry.Element(expr) + } + } ?: throw ScriptError(t.pos, "invalid list literal: expecting expression") } } } @@ -2551,11 +2558,51 @@ class Compiler( @Suppress("UNUSED_PARAMETER") isOpen: Boolean = false, isStatic: Boolean = false ): Statement { - val nameToken = cc.next() - val start = nameToken.pos - if (nameToken.type != Token.Type.ID) - throw ScriptError(nameToken.pos, "Expected identifier here") - val name = nameToken.value + val nextToken = cc.next() + val start = nextToken.pos + + if (nextToken.type == Token.Type.LBRACKET) { + // Destructuring + if (isStatic) throw ScriptError(start, "static destructuring is not supported") + + val entries = parseArrayLiteral() + val pattern = ListLiteralRef(entries) + + // Register all names in the pattern + pattern.forEachVariable { name -> declareLocalName(name) } + + val eqToken = cc.next() + if (eqToken.type != Token.Type.ASSIGN) + throw ScriptError(eqToken.pos, "destructuring declaration must be initialized") + + val initialExpression = parseStatement(true) + ?: throw ScriptError(eqToken.pos, "Expected initializer expression") + + val names = mutableListOf() + pattern.forEachVariable { names.add(it) } + + return statement(start) { context -> + val value = initialExpression.execute(context) + for (name in names) { + context.addItem(name, true, ObjVoid, visibility) + } + pattern.setAt(start, context, value) + if (!isMutable) { + for (name in names) { + val rec = context.objects[name]!! + val immutableRec = rec.copy(isMutable = false) + context.objects[name] = immutableRec + context.localBindings[name] = immutableRec + context.updateSlotFor(name, immutableRec) + } + } + ObjVoid + } + } + + if (nextToken.type != Token.Type.ID) + throw ScriptError(nextToken.pos, "Expected identifier or [ here") + val name = nextToken.value // Optional explicit type annotation val varTypeMini: MiniTypeRef? = if (cc.current().type == Token.Type.COLON) { @@ -2597,7 +2644,7 @@ class Compiler( type = varTypeMini, initRange = initR, doc = pendingDeclDoc, - nameStart = nameToken.pos + nameStart = start ) miniSink?.onValDecl(node) pendingDeclDoc = null @@ -2621,14 +2668,14 @@ class Compiler( // 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 -> + return statement(start) { context -> // 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") + throw ScriptError(start, "Variable $name is already defined") } // Register the local name so subsequent identifiers can be emitted as fast locals @@ -2661,7 +2708,7 @@ class Compiler( 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 initStmt = statement(start) { scp -> val initValue = initialExpression?.execute(scp)?.byValueCopy() ?: ObjNull // Preserve mutability of declaration: do NOT use addOrUpdateItem here, as it creates mutable records scp.addItem(storageName, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt index ae54af6..f87d845 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt @@ -297,6 +297,10 @@ open class Scope( return idx } + fun updateSlotFor(name: String, record: ObjRecord) { + nameToSlot[name]?.let { slots[it] = record } + } + /** * Reset this scope instance so it can be safely reused as a fresh child frame. * Clears locals and slots, assigns new frameId, and sets parent/args/pos/thisObj. 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 b073080..0b5a1f8 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -27,6 +27,7 @@ import net.sergeych.lyng.* */ sealed interface ObjRef { suspend fun get(scope: Scope): ObjRecord + /** * Fast path for evaluating an expression to a raw Obj value without wrapping it into ObjRecord. * Default implementation calls [get] and returns its value. Nodes can override to avoid record traffic. @@ -35,6 +36,12 @@ sealed interface ObjRef { suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) { throw ScriptError(pos, "can't assign value") } + + /** + * Calls [block] for each variable name that this reference targets for writing. + * Used for declaring local variables in destructuring. + */ + fun forEachVariable(block: (String) -> Unit) {} } /** Runtime-computed read-only reference backed by a lambda. */ @@ -1215,6 +1222,9 @@ class MethodCallRef( * Reference to a local/visible variable by name (Phase A: scope lookup). */ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef { + override fun forEachVariable(block: (String) -> Unit) { + block(name) + } // Per-frame slot cache to avoid repeated name lookups private var cachedFrameId: Long = 0L private var cachedSlot: Int = -1 @@ -1453,6 +1463,9 @@ class FastLocalVarRef( private val name: String, private val atPos: Pos, ) : ObjRef { + override fun forEachVariable(block: (String) -> Unit) { + block(name) + } // Cache the exact scope frame that owns the slot, not just the current frame private var cachedOwnerScope: Scope? = null private var cachedOwnerFrameId: Long = 0L @@ -1610,6 +1623,15 @@ class FastLocalVarRef( } class ListLiteralRef(private val entries: List) : ObjRef { + override fun forEachVariable(block: (String) -> Unit) { + for (e in entries) { + when (e) { + is ListEntry.Element -> e.ref.forEachVariable(block) + is ListEntry.Spread -> e.ref.forEachVariable(block) + } + } + } + override suspend fun get(scope: Scope): ObjRecord { // Heuristic capacity hint: count element entries; spreads handled opportunistically val elemCount = entries.count { it is ListEntry.Element } @@ -1633,7 +1655,54 @@ class ListLiteralRef(private val entries: List) : ObjRef { } } } - return ObjList(list).asReadonly + return ObjList(list).asMutable + } + + override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) { + val sourceList = (newValue as? ObjList)?.list + ?: throw ScriptError(pos, "destructuring assignment requires a list on the right side") + + val ellipsisIdx = entries.indexOfFirst { it is ListEntry.Spread } + if (entries.count { it is ListEntry.Spread } > 1) { + throw ScriptError(pos, "destructuring pattern can have only one splat") + } + + if (ellipsisIdx < 0) { + if (sourceList.size < entries.size) + throw ScriptError(pos, "too few elements for destructuring") + for (i in entries.indices) { + val entry = entries[i] + if (entry is ListEntry.Element) { + entry.ref.setAt(pos, scope, sourceList[i]) + } + } + } else { + val headCount = ellipsisIdx + val tailCount = entries.size - ellipsisIdx - 1 + if (sourceList.size < headCount + tailCount) + throw ScriptError(pos, "too few elements for destructuring") + + // head + for (i in 0 until headCount) { + val entry = entries[i] + if (entry is ListEntry.Element) { + entry.ref.setAt(pos, scope, sourceList[i]) + } + } + + // tail + for (i in 0 until tailCount) { + val entry = entries[entries.size - 1 - i] + if (entry is ListEntry.Element) { + entry.ref.setAt(pos, scope, sourceList[sourceList.size - 1 - i]) + } + } + + // ellipsis + val spreadEntry = entries[ellipsisIdx] as ListEntry.Spread + val spreadList = sourceList.subList(headCount, sourceList.size - tailCount) + spreadEntry.ref.setAt(pos, scope, ObjList(spreadList.toMutableList())) + } } } diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 60bd8b1..b45b004 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -4334,12 +4334,45 @@ class ScriptTest { } -// @Test -// fun testSplatAssignemnt() = runTest { -// eval(""" -// val abc = [1, 2, 3] -// val [a, b, c] = ...abc -// println(a, b, c) -// """.trimIndent()) -// } + @Test + fun testDestructuringAssignment() = runTest { + eval(""" + val abc = [1, 2, 3] + // plain: + val [a, b, c] = abc + assertEquals( 1, a ) + assertEquals( 2, b ) + assertEquals( 3, c ) + // with splats, receiving into list: + val [ab..., c1] = abc + assertEquals( [1, 2], ab ) + assertEquals( 3, c1 ) + // also in the end + val [a1, rest...] = abc + assertEquals( 1, a1 ) + assertEquals( [2, 3], rest ) + // and in the middle + val [a_mid, middle..., e0, e1] = [ 1, 2, 3, 4, 5, 6] + assertEquals( [2, 3, 4], middle ) + assertEquals( 5, e0 ) + assertEquals( 6, e1 ) + assertEquals( 1, a_mid ) + + // nested destructuring: + val [n1, [n2, n3...], n4] = [1, [2, 3, 4], 5] + assertEquals(1, n1) + assertEquals(2, n2) + assertEquals([3, 4], n3) + assertEquals(5, n4) + + // also it could be used to reassign vars: + var x = 5 + var y = 10 + + [x, y] = [y, x] + assertEquals( 10, x ) + assertEquals( 5, y ) + + """.trimIndent()) + } }