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; }