diff --git a/Compilation/CallHandlers/ArrayDestructureHandler.cs b/Compilation/CallHandlers/ArrayDestructureHandler.cs
index 4150b657..5d469643 100644
--- a/Compilation/CallHandlers/ArrayDestructureHandler.cs
+++ b/Compilation/CallHandlers/ArrayDestructureHandler.cs
@@ -8,13 +8,14 @@ namespace SharpTS.Compilation.CallHandlers;
///
/// Handles the internal __arrayDestructure helper that normalizes an array binding-pattern
-/// source through the iterator protocol (#685). Index-addressable sources (arrays, tuples, strings)
-/// are emitted as-is — the desugared positional index access reads them directly and the type checker
+/// source through the iterator protocol (#685). Index-addressable sources (arrays, tuples) are
+/// emitted as-is — the desugared positional index access reads them directly and the type checker
/// assigns them a matching pass-through type, so no runtime work is needed (preserves the fast path
-/// and tuple positional element types). Every other source is routed through the emitted
-/// ArrayDestructureSource runtime helper, which materializes non-indexable iterables
-/// (generators, Set, Map, [Symbol.iterator] objects) into an array and passes everything else
-/// through unchanged.
+/// and tuple positional element types). Every other source — including strings — is routed
+/// through the emitted ArrayDestructureSource runtime helper, which materializes non-indexable
+/// iterables (generators, Set, Map, strings, [Symbol.iterator] objects) into an array. Strings
+/// are deliberately not on the fast path so a rest element binds a fresh character array rather than
+/// the trailing substring (#753); non-rest character values are identical either way.
///
public class ArrayDestructureHandler : ICallHandler
{
@@ -52,6 +53,8 @@ public bool TryHandle(IEmitterContext emitter, Expr.Call call)
return true;
}
+ // Strings are intentionally excluded: they must materialize through ArrayDestructureSource so a
+ // rest element collects a fresh character array, not the trailing substring (#753).
private static bool IsIndexAddressable(TypeInfo? type) =>
- type is TypeInfo.Array or TypeInfo.Tuple or TypeInfo.String or TypeInfo.StringLiteral;
+ type is TypeInfo.Array or TypeInfo.Tuple;
}
diff --git a/Compilation/ExpressionEmitterBase.cs b/Compilation/ExpressionEmitterBase.cs
index a3a31be5..e02468dd 100644
--- a/Compilation/ExpressionEmitterBase.cs
+++ b/Compilation/ExpressionEmitterBase.cs
@@ -170,6 +170,9 @@ public virtual void EmitExpression(Expr expr)
case Expr.Comma c:
EmitComma(c);
break;
+ case Expr.DestructuringAssign da:
+ EmitDestructuringAssign(da);
+ break;
case Expr.Literal lit:
EmitLiteral(lit);
break;
@@ -1992,6 +1995,16 @@ protected virtual void EmitComma(Expr.Comma c)
EmitExpression(c.Right);
EnsureBoxed();
}
+
+ ///
+ /// Emits an assignment-destructuring expression (#754) by running its lowered statements and then
+ /// leaving the result value on the stack. Overridden in (the
+ /// concrete emitters' base), which has access to EmitStatement ; declared here only so the
+ /// shared dispatch can reach it.
+ ///
+ protected virtual void EmitDestructuringAssign(Expr.DestructuringAssign da) =>
+ throw new NotSupportedException(
+ "DestructuringAssign requires statement emission; emit it from a StatementEmitterBase subclass.");
#endregion
#region Virtual Methods - Pass-through expressions
diff --git a/Compilation/ILEmitter.Properties.cs b/Compilation/ILEmitter.Properties.cs
index 0c6a39eb..5e32c629 100644
--- a/Compilation/ILEmitter.Properties.cs
+++ b/Compilation/ILEmitter.Properties.cs
@@ -746,7 +746,14 @@ protected override void EmitGetIndex(Expr.GetIndex gi)
EmitExpressionAsDouble(gi.Index);
IL.Emit(OpCodes.Conv_I4);
IL.Emit(OpCodes.Callvirt, _ctx.Types.GetMethod(listType, "get_Item", _ctx.Types.Int32));
- SetStackType(h.Descriptor.StackType);
+ // Box the unboxed element so this branch converges on `object` with the
+ // $Array / List / fallback paths at endLabel. The typed List
+ // fast path otherwise leaves a native double/bool where the merge point — and
+ // every consumer, which reads the clobbered StackType=Unknown — expects an
+ // object ref. That ran only because the typed branch is dead for $Array-backed
+ // values, but is unverifiable IL (#751).
+ h.Descriptor.EmitBoxElement(IL, _ctx.Types);
+ SetStackUnknown();
IL.Emit(OpCodes.Br, endLabel);
// Fallback: type didn't match at loop entry
@@ -787,7 +794,10 @@ protected override void EmitGetIndex(Expr.GetIndex gi)
EmitExpressionAsDouble(gi.Index);
IL.Emit(OpCodes.Conv_I4);
IL.Emit(OpCodes.Callvirt, _ctx.Types.GetMethod(listType, "get_Item", _ctx.Types.Int32));
- SetStackType(desc.StackType);
+ // Box so this branch converges on `object` with the sibling paths at endLabelNH
+ // (see the hoisted get path above for the full rationale, #751).
+ desc.EmitBoxElement(IL, _ctx.Types);
+ SetStackUnknown();
IL.Emit(OpCodes.Br, endLabelNH);
IL.MarkLabel(notTypedLabel);
@@ -898,7 +908,10 @@ protected override void EmitSetIndex(Expr.SetIndex si)
IL.Emit(OpCodes.Ldloc, typedValueLocal);
IL.Emit(OpCodes.Call, h.Descriptor.GetSetArrayElementMethod(_ctx.Runtime!));
IL.Emit(OpCodes.Ldloc, typedValueLocal);
- SetStackType(h.Descriptor.StackType);
+ // Box the assigned value so this branch leaves `object` like the fallback path at
+ // endLabel (the assignment result is consumed via StackType=Unknown), #751.
+ h.Descriptor.EmitBoxElement(IL, _ctx.Types);
+ SetStackUnknown();
IL.Emit(OpCodes.Br, endLabel);
// Fallback: type didn't match at loop entry
@@ -970,7 +983,10 @@ protected override void EmitSetIndex(Expr.SetIndex si)
IL.Emit(OpCodes.Ldloc, typedValueLocalNH);
IL.Emit(OpCodes.Call, desc.GetSetArrayElementMethod(_ctx.Runtime!));
IL.Emit(OpCodes.Ldloc, typedValueLocalNH);
- SetStackType(desc.StackType);
+ // Box so this branch converges on `object` with the sibling paths at endLabelNH
+ // (see the hoisted set path above for the full rationale, #751).
+ desc.EmitBoxElement(IL, _ctx.Types);
+ SetStackUnknown();
IL.Emit(OpCodes.Br, endLabelNH);
// Not typed list: box value and fall through to List path
diff --git a/Compilation/RuntimeEmitter.Objects.Iteration.cs b/Compilation/RuntimeEmitter.Objects.Iteration.cs
index e2b93a82..8948d5de 100644
--- a/Compilation/RuntimeEmitter.Objects.Iteration.cs
+++ b/Compilation/RuntimeEmitter.Objects.Iteration.cs
@@ -9,13 +9,17 @@ public partial class RuntimeEmitter
{
///
/// Emits ArrayDestructureSource: normalizes an array binding-pattern source through the
- /// iterator protocol (#685). Index-addressable sources — strings and any
+ /// iterator protocol (#685). Index-addressable sources — any
/// (arrays, $Array , typed lists) — pass through
/// unchanged so the desugared positional index access reads them directly and stays consistent
/// with the matching pass-through type the type checker assigned. Any other iterable (Set, Map,
- /// generators, [Symbol.iterator] objects, IEnumerable<object> ) is materialized
- /// via IterateToList into a List<object> so positional access yields the
- /// iterated elements. Non-iterable sources pass through, preserving the existing lenient behavior.
+ /// generators, strings, [Symbol.iterator] objects, IEnumerable<object> ) is
+ /// materialized via IterateToList into a List<object> so positional access
+ /// yields the iterated elements. A string is deliberately not on the pass-through path: it
+ /// materializes to a fresh character array so a rest element binds an array rather than the trailing
+ /// substring (const [a, ...rest] = "hi" ), matching ECMA-262 (#753) — non-rest character
+ /// values are identical either way. Non-iterable sources pass through, preserving the existing
+ /// lenient behavior.
/// Signature: object ArrayDestructureSource(object value, $TSSymbol iteratorSymbol, Type runtimeType)
///
private void EmitArrayDestructureSource(TypeBuilder typeBuilder, EmittedRuntime runtime)
@@ -33,13 +37,10 @@ private void EmitArrayDestructureSource(TypeBuilder typeBuilder, EmittedRuntime
var passThroughLabel = il.DefineLabel();
- // string → pass through (the source stays typed as string; index access reads chars).
- il.Emit(OpCodes.Ldarg_0);
- il.Emit(OpCodes.Isinst, _types.String);
- il.Emit(OpCodes.Brtrue, passThroughLabel);
-
// IList (List, $Array, typed lists) → pass through: already index-addressable, and
// routing a typed list (List/List) through IterateToList would re-box it.
+ // Note: a .NET string is NOT an IList, so it falls through to the IterateToList path below and
+ // is materialized into a character array (#753) — required so a rest element binds an array.
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Isinst, ilistType);
il.Emit(OpCodes.Brtrue, passThroughLabel);
diff --git a/Compilation/RuntimeFeatureDetector.cs b/Compilation/RuntimeFeatureDetector.cs
index 50532deb..8d1454d4 100644
--- a/Compilation/RuntimeFeatureDetector.cs
+++ b/Compilation/RuntimeFeatureDetector.cs
@@ -824,6 +824,13 @@ private void VisitExpr(Expr? expr)
VisitExpr(cm.Left);
VisitExpr(cm.Right);
break;
+ case Expr.DestructuringAssign da:
+ // Walk the lowered assignment statements so a feature inside the rhs/targets
+ // (e.g. eval/Proxy in the source expression) is still detected (#754).
+ foreach (var s in da.Assignments)
+ VisitStmt(s);
+ VisitExpr(da.ResultValue);
+ break;
case Expr.Grouping gr:
VisitExpr(gr.Expression);
break;
diff --git a/Compilation/StatementEmitterBase.cs b/Compilation/StatementEmitterBase.cs
index 84efca8d..b7590d89 100644
--- a/Compilation/StatementEmitterBase.cs
+++ b/Compilation/StatementEmitterBase.cs
@@ -190,6 +190,20 @@ protected virtual void EmitTruthyCheck()
#region Core Statement Dispatch
+ ///
+ /// Emits an assignment-destructuring expression (#754): run the lowered assignment statements (temp
+ /// binding + per-target writes, all synthesized /
+ /// with no control flow), then leave the result value (the original rhs) on the stack. Each statement
+ /// goes through the normal path, so the async/generator subclasses handle
+ /// an await /yield in the rhs without any extra plumbing.
+ ///
+ protected override void EmitDestructuringAssign(Expr.DestructuringAssign da)
+ {
+ foreach (var stmt in da.Assignments)
+ EmitStatement(stmt);
+ EmitExpression(da.ResultValue);
+ }
+
///
/// Dispatches statement emission to the appropriate handler method.
///
diff --git a/Execution/Interpreter.Expressions.cs b/Execution/Interpreter.Expressions.cs
index 9af0fb25..9aab51bf 100644
--- a/Execution/Interpreter.Expressions.cs
+++ b/Execution/Interpreter.Expressions.cs
@@ -47,6 +47,14 @@ internal RuntimeValue EvaluateRV(Expr expr)
// All Evaluate* methods return RuntimeValue directly — no FromBoxed in dispatch.
internal RuntimeValue VisitComma(Expr.Comma comma) { Evaluate(comma.Left); return EvaluateRV(comma.Right); }
+ internal RuntimeValue VisitDestructuringAssign(Expr.DestructuringAssign d)
+ {
+ // Run the lowered assignment statements (temp binding + per-target writes), then yield the
+ // original rhs the temp holds — an assignment expression evaluates to its right-hand side (#754).
+ foreach (var stmt in d.Assignments)
+ Execute(stmt);
+ return EvaluateRV(d.ResultValue);
+ }
internal RuntimeValue VisitBinary(Expr.Binary binary) => EvaluateBinary(binary);
internal RuntimeValue VisitLogical(Expr.Logical logical) => EvaluateLogical(logical);
internal RuntimeValue VisitNullishCoalescing(Expr.NullishCoalescing nc) => EvaluateNullishCoalescing(nc);
@@ -115,6 +123,12 @@ internal async Task EvaluateAsync(Expr expr)
switch (expr)
{
case Expr.Comma comma: await EvaluateAsync(comma.Left); return await EvaluateAsync(comma.Right);
+ case Expr.DestructuringAssign d:
+ // Lowered statements may contain `await` in the rhs; run them on the async path, then
+ // yield the temp holding the original rhs (#754).
+ foreach (var stmt in d.Assignments)
+ await ExecuteStatementAsync(stmt);
+ return await EvaluateAsync(d.ResultValue);
case Expr.Binary binary: return await EvaluateBinaryAsync(binary);
case Expr.Logical logical: return await EvaluateLogicalAsync(logical);
case Expr.NullishCoalescing nc: return await EvaluateNullishCoalescingAsync(nc);
diff --git a/Execution/Interpreter.Statements.cs b/Execution/Interpreter.Statements.cs
index 51fd3007..8339e8f8 100644
--- a/Execution/Interpreter.Statements.cs
+++ b/Execution/Interpreter.Statements.cs
@@ -945,15 +945,19 @@ private ExecutionResult ExecuteForIn(Stmt.ForIn forIn)
///
/// Normalizes an array binding-pattern source through the iterator protocol (#685). Array
/// destructuring (const [a, b] = src ) desugars to positional index access, which is only
- /// correct for index-addressable sources. Arrays, strings, typed arrays and buffers pass through
- /// unchanged (fast path); any other source is materialized into a via
- /// — the same routine spread uses — so the index access reads
- /// the iterated elements. A genuinely non-iterable source throws "is not iterable", matching JS;
- /// the type checker already rejects those statically except behind any .
+ /// correct for index-addressable sources. Arrays, typed arrays and buffers pass through unchanged
+ /// (fast path); any other source — including strings — is materialized into a
+ /// via (the same routine spread uses)
+ /// so the index access reads the iterated elements. Strings are intentionally materialized (not on
+ /// the fast path) so a rest element binds a fresh array of characters rather than the trailing
+ /// substring (const [a, ...rest] = "hi" → rest = ["i"] ), matching ECMA-262 (#753);
+ /// non-rest character values are identical either way. A genuinely non-iterable source throws "is
+ /// not iterable", matching JS; the type checker already rejects those statically except behind
+ /// any .
///
internal object? NormalizeArrayDestructureSource(object? value)
{
- if (value is SharpTSArray or string or SharpTSTypedArray or SharpTSBuffer)
+ if (value is SharpTSArray or SharpTSTypedArray or SharpTSBuffer)
return value;
return new SharpTSArray(GetIterableElements(value).ToList());
diff --git a/Parsing/AST.cs b/Parsing/AST.cs
index 23e09adc..15c30490 100644
--- a/Parsing/AST.cs
+++ b/Parsing/AST.cs
@@ -49,6 +49,7 @@ public abstract record Expr
public static TResult Accept(Expr expr, IExprVisitor visitor) => expr switch
{
Comma e => visitor.VisitComma(e),
+ DestructuringAssign e => visitor.VisitDestructuringAssign(e),
Binary e => visitor.VisitBinary(e),
Logical e => visitor.VisitLogical(e),
NullishCoalescing e => visitor.VisitNullishCoalescing(e),
@@ -98,6 +99,20 @@ public abstract record Expr
/// Comma (sequence) expression: evaluates all sub-expressions left-to-right, returns the last value.
public record Comma(Expr Left, Expr Right) : Expr;
+ ///
+ /// Destructuring assignment to existing l-values — [a, b] = rhs / ({a, b} = rhs) (#754).
+ /// Unlike declaration destructuring (which binds new variables), the targets are existing
+ /// / / l-values. The parser lowers the
+ /// pattern (already parsed as an array/object literal) into — a temp
+ /// declaration for the rhs, the per-target assignment statements (reusing the #685 iterator-protocol
+ /// normalization __arrayDestructure and __objectRest ), and any nested temps — plus
+ /// , the temp holding the original rhs (an assignment expression
+ /// evaluates to its right-hand side, per ECMA-262). Every backend lowers it identically: run
+ /// Assignments , then yield ResultValue . Assignments contains only synthesized
+ /// and statements (no control flow), so it is
+ /// safe in any expression position.
+ ///
+ public record DestructuringAssign(List Assignments, Expr ResultValue) : Expr;
public record Binary(Expr Left, Token Operator, Expr Right) : Expr;
public record Logical(Expr Left, Token Operator, Expr Right) : Expr;
public record NullishCoalescing(Expr Left, Expr Right) : Expr;
diff --git a/Parsing/Parser.Destructuring.cs b/Parsing/Parser.Destructuring.cs
index 93c606a5..ca7679e1 100644
--- a/Parsing/Parser.Destructuring.cs
+++ b/Parsing/Parser.Destructuring.cs
@@ -267,4 +267,164 @@ [new Expr.Variable(temp), excludeKeysExpr]
return new Stmt.Sequence(statements);
}
+
+ // ============== ASSIGNMENT DESTRUCTURING (#754) ==============
+ // `[a, b] = rhs` / `({a, b} = rhs)` assign to EXISTING l-values (not new bindings). The target has
+ // already been parsed as an array/object literal (the eager-parse cover grammar); here it is
+ // reinterpreted as a pattern and lowered into assignment statements that reuse the #685
+ // iterator-protocol normalization (`__arrayDestructure`) and `__objectRest`. The rhs is evaluated
+ // once into a temp and yielded as the expression's value (ECMA-262: an assignment evaluates to its
+ // right-hand side, the ORIGINAL rhs — not the normalized array).
+
+ /// True when an `=` target parsed as an array/object literal is an assignment-destructuring
+ /// pattern (#754) rather than an ordinary assignment target.
+ private static bool IsDestructuringAssignmentTarget(Expr target) =>
+ Unwrap(target) is Expr.ArrayLiteral or Expr.ObjectLiteral;
+
+ private static Expr Unwrap(Expr e) => e is Expr.Grouping g ? Unwrap(g.Expression) : e;
+
+ private Expr BuildDestructuringAssignment(Expr pattern, Expr value, int line)
+ {
+ var stmts = new List();
+ // _destN = rhs — evaluate the source exactly once; it is also the expression's result value.
+ Token rhsTemp = GenerateTempVar(line);
+ stmts.Add(new Stmt.Var(rhsTemp, null, value));
+ LowerAssignmentTarget(pattern, new Expr.Variable(rhsTemp), stmts, line);
+ return new Expr.DestructuringAssign(stmts, new Expr.Variable(rhsTemp));
+ }
+
+ private void LowerAssignmentTarget(Expr target, Expr source, List stmts, int line)
+ {
+ switch (Unwrap(target))
+ {
+ case Expr.ArrayLiteral arr: LowerArrayAssignmentTarget(arr, source, stmts, line); break;
+ case Expr.ObjectLiteral obj: LowerObjectAssignmentTarget(obj, source, stmts, line); break;
+ default: stmts.Add(new Stmt.Expression(MakeAssignmentExpr(target, source))); break;
+ }
+ }
+
+ private void LowerArrayAssignmentTarget(Expr.ArrayLiteral arr, Expr source, List stmts, int line)
+ {
+ // _srcN = __arrayDestructure(source) — normalize any iterable to an indexable array (#685).
+ Token srcTemp = GenerateTempVar(line);
+ stmts.Add(new Stmt.Var(srcTemp, null, MakeArrayDestructureCall(source, line)));
+ Expr src = new Expr.Variable(srcTemp);
+
+ for (int i = 0; i < arr.Elements.Count; i++)
+ {
+ if (arr.IsHole(i)) continue;
+ Expr element = Unwrap(arr.Elements[i]);
+
+ if (element is Expr.Spread spread)
+ {
+ // Rest: target = _srcN.slice(i). Must be the final element.
+ LowerAssignmentTarget(spread.Expression, MakeSliceCall(src, i, line), stmts, line);
+ break;
+ }
+
+ LowerElementWithDefault(element, new Expr.GetIndex(src, new Expr.Literal((double)i)), stmts, line);
+ }
+ }
+
+ private void LowerObjectAssignmentTarget(Expr.ObjectLiteral obj, Expr source, List stmts, int line)
+ {
+ // _srcN = source — object patterns read properties directly (no iterator normalization).
+ Token srcTemp = GenerateTempVar(line);
+ stmts.Add(new Stmt.Var(srcTemp, null, source));
+ Expr src = new Expr.Variable(srcTemp);
+ var usedKeys = new List();
+
+ foreach (var prop in obj.Properties)
+ {
+ if (prop.IsSpread)
+ {
+ // Rest: target = __objectRest(_srcN, [usedKeys]). Must be last.
+ LowerAssignmentTarget(prop.Value, MakeObjectRestCall(src, usedKeys, line), stmts, line);
+ break;
+ }
+ if (prop.Kind is not Expr.ObjectPropertyKind.Value || prop.Key is null)
+ throw new Exception("Invalid assignment target."); // getters/setters/methods aren't patterns
+
+ Expr access = MakePropertyAccess(src, prop.Key, usedKeys);
+ LowerElementWithDefault(prop.Value, access, stmts, line);
+ }
+ }
+
+ // Lowers one pattern element / property value to an assignment, peeling off a default (`= expr`)
+ // that the eager parser captured as an Assign (variable target) or Set/SetIndex/SetPrivate (member
+ // target). `?? default` applies the default when the read is null/undefined (matching the existing
+ // declaration desugaring; ECMA-262 is undefined-only — tracked separately).
+ private void LowerElementWithDefault(Expr element, Expr access, List stmts, int line)
+ {
+ switch (Unwrap(element))
+ {
+ case Expr.Assign def:
+ LowerAssignmentTarget(new Expr.Variable(def.Name),
+ new Expr.NullishCoalescing(access, def.Value), stmts, line);
+ break;
+ case Expr.Set def:
+ LowerAssignmentTarget(new Expr.Get(def.Object, def.Name),
+ new Expr.NullishCoalescing(access, def.Value), stmts, line);
+ break;
+ case Expr.SetIndex def:
+ LowerAssignmentTarget(new Expr.GetIndex(def.Object, def.Index),
+ new Expr.NullishCoalescing(access, def.Value), stmts, line);
+ break;
+ case Expr.SetPrivate def:
+ LowerAssignmentTarget(new Expr.GetPrivate(def.Object, def.Name),
+ new Expr.NullishCoalescing(access, def.Value), stmts, line);
+ break;
+ case Expr.DestructuringAssign:
+ // A nested pattern WITH a default (`[[a] = []]`, `{p: {x} = {}}`) was eagerly lowered,
+ // losing its raw target; recovering it needs cover-grammar reparsing. Rare — see #779.
+ throw new Exception("Nested destructuring with a default is not supported in assignment destructuring.");
+ default:
+ LowerAssignmentTarget(element, access, stmts, line);
+ break;
+ }
+ }
+
+ private static Expr MakeAssignmentExpr(Expr target, Expr value) => Unwrap(target) switch
+ {
+ Expr.Variable v => new Expr.Assign(v.Name, value),
+ Expr.Get g => new Expr.Set(g.Object, g.Name, value),
+ Expr.GetIndex gi => new Expr.SetIndex(gi.Object, gi.Index, value),
+ Expr.GetPrivate gp => new Expr.SetPrivate(gp.Object, gp.Name, value),
+ _ => throw new Exception("Invalid assignment target.")
+ };
+
+ private Expr MakePropertyAccess(Expr src, Expr.PropertyKey key, List usedKeys)
+ {
+ switch (key)
+ {
+ case Expr.IdentifierKey ik:
+ usedKeys.Add(new Expr.Literal(ik.Name.Lexeme));
+ return new Expr.Get(src, ik.Name);
+ case Expr.LiteralKey lk:
+ string name = lk.Literal.Type == TokenType.STRING
+ ? (string)lk.Literal.Literal!
+ : lk.Literal.Literal!.ToString()!;
+ usedKeys.Add(new Expr.Literal(name));
+ return new Expr.GetIndex(src, new Expr.Literal(name));
+ case Expr.ComputedKey ck:
+ // A computed key is dynamic, so it cannot be added to the static rest-exclusion list
+ // (a `{[k]: v, ...rest}` pattern would over-include — a narrow, rare edge).
+ return new Expr.GetIndex(src, ck.Expression);
+ default:
+ throw new Exception("Invalid assignment target.");
+ }
+ }
+
+ private static Expr MakeArrayDestructureCall(Expr source, int line) => new Expr.Call(
+ new Expr.Variable(new Token(TokenType.IDENTIFIER, "__arrayDestructure", null, line)),
+ new Token(TokenType.RIGHT_PAREN, ")", null, line), null, [source]);
+
+ private static Expr MakeSliceCall(Expr src, int index, int line) => new Expr.Call(
+ new Expr.Get(src, new Token(TokenType.IDENTIFIER, "slice", null, line)),
+ new Token(TokenType.RIGHT_PAREN, ")", null, line), null, [new Expr.Literal((double)index)]);
+
+ private static Expr MakeObjectRestCall(Expr src, List usedKeys, int line) => new Expr.Call(
+ new Expr.Variable(new Token(TokenType.IDENTIFIER, "__objectRest", null, line)),
+ new Token(TokenType.RIGHT_PAREN, ")", null, line), null,
+ [src, new Expr.ArrayLiteral(usedKeys)]);
}
diff --git a/Parsing/Parser.Expressions.cs b/Parsing/Parser.Expressions.cs
index 5f70b7fc..5466e991 100644
--- a/Parsing/Parser.Expressions.cs
+++ b/Parsing/Parser.Expressions.cs
@@ -62,7 +62,12 @@ private Expr Assignment()
if (Match(TokenType.EQUAL))
{
+ Token equals = Previous();
Expr value = Assignment();
+ // Array/object literal on the left of `=` is assignment destructuring (#754): the targets
+ // are existing l-values, lowered to assignment statements reusing the #685 iterator path.
+ if (IsDestructuringAssignmentTarget(expr))
+ return BuildDestructuringAssignment(expr, value, equals.Line);
return DispatchAssignmentTarget(expr, value, "Invalid assignment target.",
onVariable: (name, val) => new Expr.Assign(name, val),
onGet: (get, val) => new Expr.Set(get.Object, get.Name, val),
diff --git a/Parsing/Visitors/AstVisitorBase.cs b/Parsing/Visitors/AstVisitorBase.cs
index 7ef7296e..7d855c74 100644
--- a/Parsing/Visitors/AstVisitorBase.cs
+++ b/Parsing/Visitors/AstVisitorBase.cs
@@ -26,6 +26,7 @@ public virtual void Visit(Expr expr)
switch (expr)
{
case Expr.Comma e: VisitComma(e); break;
+ case Expr.DestructuringAssign e: VisitDestructuringAssign(e); break;
case Expr.Binary e: VisitBinary(e); break;
case Expr.Logical e: VisitLogical(e); break;
case Expr.NullishCoalescing e: VisitNullishCoalescing(e); break;
@@ -131,6 +132,15 @@ protected virtual void VisitComma(Expr.Comma expr)
Visit(expr.Right);
}
+ protected virtual void VisitDestructuringAssign(Expr.DestructuringAssign expr)
+ {
+ // The lowered assignment statements carry the rhs, per-target writes, and any nested temps;
+ // walking them (plus the result value) exposes every captured variable / declared temp.
+ foreach (var stmt in expr.Assignments)
+ Visit(stmt);
+ Visit(expr.ResultValue);
+ }
+
protected virtual void VisitBinary(Expr.Binary expr)
{
Visit(expr.Left);
diff --git a/Parsing/Visitors/IExprVisitor.cs b/Parsing/Visitors/IExprVisitor.cs
index 04517bcc..a2fb3672 100644
--- a/Parsing/Visitors/IExprVisitor.cs
+++ b/Parsing/Visitors/IExprVisitor.cs
@@ -13,6 +13,7 @@ namespace SharpTS.Parsing.Visitors;
public interface IExprVisitor
{
TResult VisitComma(Expr.Comma expr);
+ TResult VisitDestructuringAssign(Expr.DestructuringAssign expr);
TResult VisitBinary(Expr.Binary expr);
TResult VisitLogical(Expr.Logical expr);
TResult VisitNullishCoalescing(Expr.NullishCoalescing expr);
diff --git a/SharpTS.Tests/CompilerTests/ILVerificationTests.cs b/SharpTS.Tests/CompilerTests/ILVerificationTests.cs
index 12669015..5fa2d8ff 100644
--- a/SharpTS.Tests/CompilerTests/ILVerificationTests.cs
+++ b/SharpTS.Tests/CompilerTests/ILVerificationTests.cs
@@ -1490,6 +1490,56 @@ class Holder { constructor(public when: Date) {} }
// T (so storing the $Undefined sentinel threw InvalidCastException), or double? for a value T
// (unverifiable IL). Such parameters must use an object slot, as locals already do. The body
// reassigns the parameter to `undefined` and observes it, exercising both store and read.
+ // #751: indexing a typed-array (number[]/boolean[]) variable emitted an unverifiable merge — the
+ // typed List fast path left a native double/bool on the stack where the sibling
+ // $Array/List/fallback paths (and every consumer, which reads the clobbered
+ // StackType=Unknown) expected an object ref. It ran only because the typed branch is dead for an
+ // $Array-backed value. The fast path now boxes its result so every branch converges on object.
+ // Covers GET and SET, number and boolean, typed-backed (empty literal + push) and object-backed
+ // (non-empty literal) arrays, and the original spread/destructure-of-a-numeric-Set repro.
+ [Fact]
+ public void TypedArrayIndexReadAndWrite_PassesILVerification()
+ {
+ var source = """
+ const a: number[] = [1, 2, 3];
+ const x = a[0];
+ a[1] = 9;
+ const b: boolean[] = [true, false];
+ const y = b[0];
+ b[1] = true;
+ const c: number[] = [];
+ c.push(5);
+ let sum = 0;
+ for (let i = 0; i < 3; i++) sum += a[i];
+ console.log(x, a[1], y, b[1], c[0], sum);
+ """;
+
+ var (errors, output) = TestHarness.CompileVerifyAndRun(source);
+
+ Assert.Empty(errors);
+ Assert.Equal("1 9 true true 5 13\n", output);
+ Assert.Equal(output, TestHarness.RunInterpreted(source));
+ }
+
+ [Fact]
+ public void NumericSetSpreadAndDestructure_PassesILVerification()
+ {
+ // #751 original repro: materializing a numeric Set via spread, then index-reading the
+ // resulting number[], and array-destructuring a numeric Set (inherits the same path).
+ var source = """
+ const out = [...new Set([1, 2, 3])];
+ console.log(out[0], out.length);
+ const [first, second] = new Set([10, 20]);
+ console.log(first, second);
+ """;
+
+ var (errors, output) = TestHarness.CompileVerifyAndRun(source);
+
+ Assert.Empty(errors);
+ Assert.Equal("1 3\n10 20\n", output);
+ Assert.Equal(output, TestHarness.RunInterpreted(source));
+ }
+
[Fact]
public void UndefinedUnionParameterReassignment_PassesILVerification()
{
diff --git a/SharpTS.Tests/ParsingTests/AstVisitorTests.cs b/SharpTS.Tests/ParsingTests/AstVisitorTests.cs
index 9da90d04..202dbaa0 100644
--- a/SharpTS.Tests/ParsingTests/AstVisitorTests.cs
+++ b/SharpTS.Tests/ParsingTests/AstVisitorTests.cs
@@ -15,6 +15,7 @@ public class AstVisitorTests
private class ExprTypeCollector : IExprVisitor
{
public Type VisitComma(Expr.Comma expr) => typeof(Expr.Comma);
+ public Type VisitDestructuringAssign(Expr.DestructuringAssign expr) => typeof(Expr.DestructuringAssign);
public Type VisitBinary(Expr.Binary expr) => typeof(Expr.Binary);
public Type VisitLogical(Expr.Logical expr) => typeof(Expr.Logical);
public Type VisitNullishCoalescing(Expr.NullishCoalescing expr) => typeof(Expr.NullishCoalescing);
diff --git a/SharpTS.Tests/SharedTests/DestructuringTests.cs b/SharpTS.Tests/SharedTests/DestructuringTests.cs
index 360821a5..0b087556 100644
--- a/SharpTS.Tests/SharedTests/DestructuringTests.cs
+++ b/SharpTS.Tests/SharedTests/DestructuringTests.cs
@@ -339,4 +339,148 @@ public void MixedDestructuring_ObjectWithArray(ExecutionMode mode)
}
#endregion
+
+ #region String Rest Element (#753)
+
+ // #753: a rest element over a STRING source must collect a fresh ARRAY of characters, not bind
+ // the trailing substring. Non-rest character bindings are identical either way.
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void ArrayDestructuring_StringRest_BindsCharArray(ExecutionMode mode)
+ {
+ var source = """
+ const [a, ...rest] = "hello";
+ console.log(a);
+ console.log(Array.isArray(rest));
+ console.log(rest.length);
+ console.log(rest.join("-"));
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("h\ntrue\n4\ne-l-l-o\n", output);
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void ArrayDestructuring_StringNonRest_Unchanged(ExecutionMode mode)
+ {
+ var source = """
+ const [a, b, c = "Z"] = "hi";
+ console.log(a, b, c);
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("h i Z\n", output);
+ }
+
+ #endregion
+
+ #region Assignment Destructuring (#754)
+
+ // #754: destructuring assignment to EXISTING l-values (no const/let), array and object patterns.
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AssignmentDestructuring_ArrayBasicAndSwap(ExecutionMode mode)
+ {
+ var source = """
+ let a = 0, b = 0;
+ [a, b] = [1, 2];
+ console.log(a, b);
+ [a, b] = [b, a];
+ console.log(a, b);
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("1 2\n2 1\n", output);
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AssignmentDestructuring_DefaultsHolesAndRest(ExecutionMode mode)
+ {
+ var source = """
+ let c = 0, d = 0;
+ [c, d = 9] = [5];
+ console.log(c, d);
+ let x, y;
+ [, x, , y] = [1, 2, 3, 4];
+ console.log(x, y);
+ let head: number, tail: number[];
+ [head, ...tail] = [1, 2, 3, 4];
+ console.log(head, Array.isArray(tail), tail.join(","));
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("5 9\n2 4\n1 true 2,3,4\n", output);
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AssignmentDestructuring_MemberTargets(ExecutionMode mode)
+ {
+ var source = """
+ const o: any = {};
+ const arr: number[] = [0, 0];
+ [o.p, arr[1]] = [10, 20];
+ console.log(o.p, arr[1]);
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("10 20\n", output);
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AssignmentDestructuring_Object_RenameRestAndDefault(ExecutionMode mode)
+ {
+ var source = """
+ let pa: number, pb: number, rr: any;
+ ({ a: pa, b: pb, ...rr } = { a: 1, b: 2, z: 9 });
+ console.log(pa, pb, JSON.stringify(rr));
+ const src: any = { p: 3 };
+ let ox, oy;
+ ({ p: ox, q: oy = 4 } = src);
+ console.log(ox, oy);
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("1 2 {\"z\":9}\n3 4\n", output);
+ }
+
+ // The right-hand side is normalized through the #685 iterator protocol, so a non-indexable
+ // iterable (Set) destructures by assignment just like a declaration.
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AssignmentDestructuring_IteratorSource(ExecutionMode mode)
+ {
+ var source = """
+ let s1, s2;
+ [s1, s2] = new Set([11, 22]);
+ console.log(s1, s2);
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("11 22\n", output);
+ }
+
+ // An assignment expression evaluates to its right-hand side (the ORIGINAL rhs, not the
+ // normalized array), so it composes in expression position and chains.
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AssignmentDestructuring_ExpressionPositionAndChained(ExecutionMode mode)
+ {
+ var source = """
+ let e1, e2;
+ const ret = ([e1, e2] = [100, 200]);
+ console.log(JSON.stringify(ret), e1, e2);
+ let a, b, c, d;
+ [a, b] = [c, d] = [1, 2];
+ console.log(a, b, c, d);
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("[100,200] 100 200\n1 2 1 2\n", output);
+ }
+
+ #endregion
}
diff --git a/TypeSystem/ControlFlow/LoopAssignmentAnalyzer.cs b/TypeSystem/ControlFlow/LoopAssignmentAnalyzer.cs
index 23cc4303..959b1bbe 100644
--- a/TypeSystem/ControlFlow/LoopAssignmentAnalyzer.cs
+++ b/TypeSystem/ControlFlow/LoopAssignmentAnalyzer.cs
@@ -151,6 +151,14 @@ private static void CollectAssignedPathsFromExpr(Expr expr, HashSet CheckBinary(expr);
internal TypeInfo VisitLogical(Expr.Logical expr) => CheckLogical(expr);
diff --git a/TypeSystem/TypeChecker.Iterables.cs b/TypeSystem/TypeChecker.Iterables.cs
index ce8ca189..3718d4ce 100644
--- a/TypeSystem/TypeChecker.Iterables.cs
+++ b/TypeSystem/TypeChecker.Iterables.cs
@@ -210,12 +210,15 @@ private bool TryGetIterableElementType(TypeInfo type, out TypeInfo elementType)
///
/// Computes the static type produced by the __arrayDestructure helper (#685), which
/// normalizes an array binding-pattern source through the iterator protocol. Index-addressable
- /// sources (arrays, tuples, strings, any ) pass through with their precise type so the
- /// desugared positional index access stays accurate — notably tuples keep their per-position
- /// element types. Any other iterable (Set, Map, generators, [Symbol.iterator] objects)
- /// becomes Array<element> , so the subsequent _dest0[i] reads the element type
- /// instead of erroring. A non-iterable, non-indexable source is returned unchanged so the existing
- /// index-access diagnostic still fires.
+ /// sources (arrays, tuples, any ) pass through with their precise type so the desugared
+ /// positional index access stays accurate — notably tuples keep their per-position element types.
+ /// Any other iterable (Set, Map, generators, [Symbol.iterator] objects) becomes
+ /// Array<element> , so the subsequent _dest0[i] reads the element type instead of
+ /// erroring. A string is deliberately NOT passed through: it iterates to string[] so a
+ /// rest element binds a fresh array (const [a, ...rest] = "hi" → rest: string[] ),
+ /// matching ECMA-262 instead of binding the trailing substring (#753); non-rest element types are
+ /// unchanged (string either way). A non-iterable, non-indexable source is returned unchanged
+ /// so the existing index-access diagnostic still fires.
///
private TypeInfo NormalizeArrayDestructureSourceType(TypeInfo sourceType)
{
@@ -223,8 +226,6 @@ private TypeInfo NormalizeArrayDestructureSourceType(TypeInfo sourceType)
{
case TypeInfo.Array:
case TypeInfo.Tuple:
- case TypeInfo.String:
- case TypeInfo.StringLiteral:
case TypeInfo.Any:
return sourceType;
}