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])
>>> 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:

View File

@ -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

View File

@ -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:

View File

@ -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<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
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)

View File

@ -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.

View File

@ -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<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 {
// 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<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
// 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())
}
}