fix #95 destructuring assignment and splats

This commit is contained in:
Sergey Chernov 2025-12-22 06:20:24 +01:00
parent 3acd56f55a
commit 0add0ab54c
7 changed files with 244 additions and 20 deletions

View File

@ -92,6 +92,34 @@ Open end ranges remove head and tail elements:
assert( [1, 2, 3] !== [1, 2, 3]) assert( [1, 2, 3] !== [1, 2, 3])
>>> void >>> 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 ## In-place sort
List could be sorted in place, just like [Collection] provide sorted copies, in a very like way: List could be sorted in place, just like [Collection] provide sorted copies, in a very like way:

View File

@ -75,6 +75,13 @@ destructuring arrays when calling functions and lambdas:
getFirstAndLast( ...(1..10) ) // see "splats" section below getFirstAndLast( ...(1..10) ) // see "splats" section below
>>> [1,10] >>> [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 # Splats
Ellipsis allows to convert argument lists to lists. The inversa algorithm that converts [List], 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 [tutorial]: tutorial.md
[List]: List.md

View File

@ -107,6 +107,41 @@ Assignemnt is an expression that changes its lvalue and return assigned value:
>>> 11 >>> 11
>>> 6 >>> 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 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 to use parentheses as assignment operation insofar is left-associated and will not
allow chained assignments (we might fix it later). Use parentheses insofar: allow chained assignments (we might fix it later). Use parentheses insofar:

View File

@ -674,12 +674,19 @@ class Compiler(
Token.Type.RBRACKET -> return entries Token.Type.RBRACKET -> return entries
Token.Type.ELLIPSIS -> { Token.Type.ELLIPSIS -> {
parseExpressionLevel()?.let { entries += ListEntry.Spread(it) } parseExpressionLevel()?.let { entries += ListEntry.Spread(it) }
?: throw ScriptError(t.pos, "spread element must have an expression")
} }
else -> { else -> {
cc.previous() cc.previous()
parseExpressionLevel()?.let { entries += ListEntry.Element(it) } parseExpressionLevel()?.let { expr ->
?: throw ScriptError(t.pos, "invalid list literal: expecting expression") 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, @Suppress("UNUSED_PARAMETER") isOpen: Boolean = false,
isStatic: Boolean = false isStatic: Boolean = false
): Statement { ): Statement {
val nameToken = cc.next() val nextToken = cc.next()
val start = nameToken.pos val start = nextToken.pos
if (nameToken.type != Token.Type.ID)
throw ScriptError(nameToken.pos, "Expected identifier here") if (nextToken.type == Token.Type.LBRACKET) {
val name = nameToken.value // 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<String>()
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 // Optional explicit type annotation
val varTypeMini: MiniTypeRef? = if (cc.current().type == Token.Type.COLON) { val varTypeMini: MiniTypeRef? = if (cc.current().type == Token.Type.COLON) {
@ -2597,7 +2644,7 @@ class Compiler(
type = varTypeMini, type = varTypeMini,
initRange = initR, initRange = initR,
doc = pendingDeclDoc, doc = pendingDeclDoc,
nameStart = nameToken.pos nameStart = start
) )
miniSink?.onValDecl(node) miniSink?.onValDecl(node)
pendingDeclDoc = null pendingDeclDoc = null
@ -2621,14 +2668,14 @@ class Compiler(
// Determine declaring class (if inside class body) at compile time, capture it in the closure // Determine declaring class (if inside class body) at compile time, capture it in the closure
val declaringClassNameCaptured = (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.name 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 // 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 // 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. // ClassBody qualifies for class-field storage. Otherwise, this is a plain local.
val declaringClassName = declaringClassNameCaptured val declaringClassName = declaringClassNameCaptured
if (declaringClassName == null) { if (declaringClassName == null) {
if (context.containsLocal(name)) 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 // Register the local name so subsequent identifiers can be emitted as fast locals
@ -2661,7 +2708,7 @@ class Compiler(
if (isClassScope) { if (isClassScope) {
val cls = context.thisObj as ObjClass val cls = context.thisObj as ObjClass
// Defer: at instance construction, evaluate initializer in instance scope and store under mangled name // 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 val initValue = initialExpression?.execute(scp)?.byValueCopy() ?: ObjNull
// Preserve mutability of declaration: do NOT use addOrUpdateItem here, as it creates mutable records // Preserve mutability of declaration: do NOT use addOrUpdateItem here, as it creates mutable records
scp.addItem(storageName, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) scp.addItem(storageName, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field)

View File

@ -297,6 +297,10 @@ open class Scope(
return idx 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. * 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. * Clears locals and slots, assigns new frameId, and sets parent/args/pos/thisObj.

View File

@ -27,6 +27,7 @@ import net.sergeych.lyng.*
*/ */
sealed interface ObjRef { sealed interface ObjRef {
suspend fun get(scope: Scope): ObjRecord suspend fun get(scope: Scope): ObjRecord
/** /**
* Fast path for evaluating an expression to a raw Obj value without wrapping it into 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. * 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) { suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
throw ScriptError(pos, "can't assign value") 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. */ /** 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). * Reference to a local/visible variable by name (Phase A: scope lookup).
*/ */
class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef { 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 // Per-frame slot cache to avoid repeated name lookups
private var cachedFrameId: Long = 0L private var cachedFrameId: Long = 0L
private var cachedSlot: Int = -1 private var cachedSlot: Int = -1
@ -1453,6 +1463,9 @@ class FastLocalVarRef(
private val name: String, private val name: String,
private val atPos: Pos, private val atPos: Pos,
) : ObjRef { ) : ObjRef {
override fun forEachVariable(block: (String) -> Unit) {
block(name)
}
// Cache the exact scope frame that owns the slot, not just the current frame // Cache the exact scope frame that owns the slot, not just the current frame
private var cachedOwnerScope: Scope? = null private var cachedOwnerScope: Scope? = null
private var cachedOwnerFrameId: Long = 0L private var cachedOwnerFrameId: Long = 0L
@ -1610,6 +1623,15 @@ class FastLocalVarRef(
} }
class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef { class ListLiteralRef(private val entries: List<ListEntry>) : 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 { override suspend fun get(scope: Scope): ObjRecord {
// Heuristic capacity hint: count element entries; spreads handled opportunistically // Heuristic capacity hint: count element entries; spreads handled opportunistically
val elemCount = entries.count { it is ListEntry.Element } val elemCount = entries.count { it is ListEntry.Element }
@ -1633,7 +1655,54 @@ class ListLiteralRef(private val entries: List<ListEntry>) : 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()))
}
} }
} }

View File

@ -4334,12 +4334,45 @@ class ScriptTest {
} }
// @Test @Test
// fun testSplatAssignemnt() = runTest { fun testDestructuringAssignment() = runTest {
// eval(""" eval("""
// val abc = [1, 2, 3] val abc = [1, 2, 3]
// val [a, b, c] = ...abc // plain:
// println(a, b, c) val [a, b, c] = abc
// """.trimIndent()) 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())
}
} }