diff --git a/Execution/Interpreter.Statements.cs b/Execution/Interpreter.Statements.cs index 76d235a4..207d861c 100644 --- a/Execution/Interpreter.Statements.cs +++ b/Execution/Interpreter.Statements.cs @@ -961,20 +961,31 @@ 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, 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. + /// correct for index-addressable sources. Plain arrays pass through unchanged (fast path). Typed + /// arrays and buffers are index-addressable but NOT iterable via , + /// so they are materialized element-by-element into a fresh ; any other + /// source — including strings — is materialized via (the + /// same routine spread uses) so the index access reads the iterated elements. Strings (#753) and + /// typed arrays/buffers (#781) are intentionally materialized (not on the fast path) so a rest + /// element binds a fresh Array rather than the trailing substring / typed-array slice + /// (const [a, ...rest] = "hi"rest = ["i"]; const [a, ...rest] = u8 → + /// rest is a real Array), matching ECMA-262; non-rest element 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 SharpTSTypedArray or SharpTSBuffer) - return value; + switch (value) + { + case SharpTSArray: + return value; + // Typed arrays / buffers expose index access but no [Symbol.iterator] in this runtime, so + // GetIterableElements would throw; read their elements directly into a fresh Array (#781). + case SharpTSTypedArray typed: + return typed.ToArray(); + case SharpTSBuffer buffer: + return new SharpTSArray(buffer.Data.Select(b => (object?)(double)b).ToList()); + } return new SharpTSArray(GetIterableElements(value).ToList()); } diff --git a/Parsing/AST.cs b/Parsing/AST.cs index 8b94edb5..38817e18 100644 --- a/Parsing/AST.cs +++ b/Parsing/AST.cs @@ -111,8 +111,18 @@ public record Comma(Expr Left, Expr Right) : Expr; /// 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; + /// / retain the un-lowered pattern and + /// default RHS so that when this node was eager-parsed as a nested element WITH a default + /// ([[a] = []], {p: {x} = {}}) the outer pattern walk can re-lower the inner pattern + /// against the defaulted access instead of the pre-built (wrong-source) statements (#779). Both are + /// null for a top-level assignment-destructuring, where only Assignments/ResultValue are + /// used; backends never read them. + /// + public record DestructuringAssign( + List Assignments, + Expr ResultValue, + Expr? RawTarget = null, + Expr? RawDefault = null) : 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; @@ -189,12 +199,17 @@ public enum ObjectPropertyKind { Value, Getter, Setter, Method } /// Whether this is a spread property (...obj) /// The kind of property (value, getter, setter, method) /// The setter parameter (for Kind=Setter only) + /// True for the cover-grammar form { a = 5 } (an ES + /// CoverInitializedName). The value is stored as Expr.Assign(a, 5) so it round-trips to the + /// #754 assignment-destructuring lowering; this flag distinguishes it from the legal expression + /// { a: a = 5 } so a pure-expression object literal can be rejected as tsc does (#780). public record Property( PropertyKey? Key, Expr Value, bool IsSpread = false, ObjectPropertyKind Kind = ObjectPropertyKind.Value, - Stmt.Parameter? SetterParam = null); + Stmt.Parameter? SetterParam = null, + bool IsShorthandDefault = false); public record GetIndex(Expr Object, Expr Index, bool Optional = false) : Expr; public record SetIndex(Expr Object, Expr Index, Expr Value) : Expr; public record Super(Token Keyword, Token? Method) : Expr; // Method is null for super() constructor calls diff --git a/Parsing/Parser.Destructuring.cs b/Parsing/Parser.Destructuring.cs index ca7679e1..15c10b17 100644 --- a/Parsing/Parser.Destructuring.cs +++ b/Parsing/Parser.Destructuring.cs @@ -190,10 +190,10 @@ [new Expr.Literal((double)index)] if (element.Pattern is IdentifierPattern id) { - // Apply default value if present + // Apply default value if present (undefined-only, #784) if (id.DefaultValue != null) { - accessExpr = new Expr.NullishCoalescing(accessExpr, id.DefaultValue); + accessExpr = DefaultIfUndefined(accessExpr, id.DefaultValue, statements, pattern.Line); } statements.Add(new Stmt.Var(id.Name, null, accessExpr)); } @@ -247,11 +247,11 @@ [new Expr.Variable(temp), excludeKeysExpr] if (prop.Value is IdentifierPattern id) { - // Apply default value if present + // Apply default value if present (undefined-only, #784) Expr? defaultVal = prop.DefaultValue ?? id.DefaultValue; if (defaultVal != null) { - accessExpr = new Expr.NullishCoalescing(accessExpr, defaultVal); + accessExpr = DefaultIfUndefined(accessExpr, defaultVal, statements, pattern.Line); } statements.Add(new Stmt.Var(id.Name, null, accessExpr)); } @@ -290,7 +290,9 @@ private Expr BuildDestructuringAssignment(Expr pattern, Expr value, int line) 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)); + // Retain the raw (pattern, default) so this node can be re-lowered if it turns out to be a nested + // element WITH a default (`[[a] = []]`) — see LowerElementWithDefault (#779). + return new Expr.DestructuringAssign(stmts, new Expr.Variable(rhsTemp), RawTarget: pattern, RawDefault: value); } private void LowerAssignmentTarget(Expr target, Expr source, List stmts, int line) @@ -352,38 +354,57 @@ private void LowerObjectAssignmentTarget(Expr.ObjectLiteral obj, Expr source, Li // 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). + // target). The default is applied only when the read is `undefined` (ECMA-262 AssignmentElement), + // via the shared DefaultIfUndefined helper (#784). 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); + DefaultIfUndefined(access, def.Value, stmts, line), stmts, line); break; case Expr.Set def: LowerAssignmentTarget(new Expr.Get(def.Object, def.Name), - new Expr.NullishCoalescing(access, def.Value), stmts, line); + DefaultIfUndefined(access, def.Value, stmts, line), stmts, line); break; case Expr.SetIndex def: LowerAssignmentTarget(new Expr.GetIndex(def.Object, def.Index), - new Expr.NullishCoalescing(access, def.Value), stmts, line); + DefaultIfUndefined(access, def.Value, stmts, line), stmts, line); break; case Expr.SetPrivate def: LowerAssignmentTarget(new Expr.GetPrivate(def.Object, def.Name), - new Expr.NullishCoalescing(access, def.Value), stmts, line); + DefaultIfUndefined(access, def.Value, stmts, line), stmts, line); + break; + case Expr.DestructuringAssign { RawTarget: { } rawTarget, RawDefault: { } rawDefault }: + // A nested pattern WITH a default (`[[a] = []]`, `{p: {x} = {}}`): the eager parser lowered + // the inner `[a] = []` to a DestructuringAssign but kept its raw (target, default), so + // re-lower the inner pattern against the defaulted access — discarding the inner node's + // pre-built statements, which bound against the wrong (inner-rhs) source (#779). + LowerAssignmentTarget(rawTarget, DefaultIfUndefined(access, rawDefault, stmts, line), 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; } } + // Destructuring defaults are applied ONLY when the matched value is `undefined`, never `null` + // (ECMA-262 ArrayBindingPattern / ObjectBindingPattern / AssignmentElement) — unlike `??`, which + // also replaces null. The access is spilled into a temp so it is evaluated exactly once (a property + // read may invoke a getter) and the ternary's repeated operand is a side-effect-free temp read. + // Shared by both the declaration desugaring and the #754 assignment-destructuring lowering. #784 + private Expr DefaultIfUndefined(Expr access, Expr defaultValue, List stmts, int line) + { + Token valueTemp = GenerateTempVar(line); + stmts.Add(new Stmt.Var(valueTemp, null, access)); + Expr isUndefined = new Expr.Binary( + new Expr.Variable(valueTemp), + new Token(TokenType.EQUAL_EQUAL_EQUAL, "===", null, line), + new Expr.Literal(SharpTS.Runtime.Types.SharpTSUndefined.Instance)); + return new Expr.Ternary(isUndefined, defaultValue, new Expr.Variable(valueTemp)); + } + private static Expr MakeAssignmentExpr(Expr target, Expr value) => Unwrap(target) switch { Expr.Variable v => new Expr.Assign(v.Name, value), diff --git a/Parsing/Parser.Expressions.cs b/Parsing/Parser.Expressions.cs index 5466e991..4c3b3be3 100644 --- a/Parsing/Parser.Expressions.cs +++ b/Parsing/Parser.Expressions.cs @@ -954,6 +954,7 @@ private Expr Primary() // Parse regular identifier (including 'get' and 'set' as property names) Token name = ConsumePropertyName("Expect property name."); Expr value; + bool isShorthandDefault = false; if (Check(TokenType.LEFT_PAREN)) { @@ -965,13 +966,23 @@ private Expr Primary() // Explicit property: { x: value } value = Expression(); } + else if (Match(TokenType.EQUAL)) + { + // Cover-grammar shorthand-with-default: `{ x = 5 }` (an ES CoverInitializedName). + // Only valid as an object DESTRUCTURING pattern; stored as `{ x: (x = 5) }` so the + // #754 assignment-destructuring lowering recovers the `(target, default)`. A + // pure-expression `{ x = 5 }` is rejected in CheckObject via IsShorthandDefault (#780). + value = new Expr.Assign(name, Expression()); + isShorthandDefault = true; + } else { // Shorthand property: { x } -> { x: x } value = new Expr.Variable(name); } - properties.Add(new Expr.Property(new Expr.IdentifierKey(name), value)); + properties.Add(new Expr.Property(new Expr.IdentifierKey(name), value, + IsShorthandDefault: isShorthandDefault)); } while (Match(TokenType.COMMA)); } Consume(TokenType.RIGHT_BRACE, "Expect '}' after object literal."); diff --git a/SharpTS.Tests/SharedTests/DestructuringTests.cs b/SharpTS.Tests/SharedTests/DestructuringTests.cs index 0b087556..7bfa1110 100644 --- a/SharpTS.Tests/SharedTests/DestructuringTests.cs +++ b/SharpTS.Tests/SharedTests/DestructuringTests.cs @@ -1,4 +1,5 @@ using SharpTS.Tests.Infrastructure; +using SharpTS.TypeSystem.Exceptions; using Xunit; namespace SharpTS.Tests.SharedTests; @@ -483,4 +484,164 @@ public void AssignmentDestructuring_ExpressionPositionAndChained(ExecutionMode m } #endregion + + #region Undefined-only Defaults (#784) + + // #784: a destructuring default applies ONLY when the matched value is `undefined`, never `null` + // (ECMA-262), unlike `??`. Declaration form, array and object patterns. + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Defaults_AppliedForUndefinedNotNull_Declaration(ExecutionMode mode) + { + var source = """ + const [a = 9] = [null]; + const [b = 9] = [undefined]; + const o: { x?: number | null } = { x: null }; + const { x = 9 } = o; + console.log(String(a), String(b), String(x)); + """; + + var output = TestHarness.Run(source, mode); + Assert.Equal("null 9 null\n", output); + } + + // #784: same undefined-only rule for assignment destructuring (#754 form), AND a value-type default + // over an ABSENT element must yield the default — not NaN. The lowered spill temp lives inside an + // expression, so an unboxed numeric slot would coerce the runtime `undefined` sentinel (compiled). + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Defaults_AssignmentDestructuring_UndefinedOnlyAndValueType(ExecutionMode mode) + { + var source = """ + let a; [a = 5] = [null]; + let b; [b = 5] = []; + const arr: number[] = []; + let c; [c = 5] = arr; + const o: { z?: number } = {}; + let d; ({ z: d = 5 } = o); + console.log(String(a), String(b), String(c), String(d)); + """; + + var output = TestHarness.Run(source, mode); + Assert.Equal("null 5 5 5\n", output); + } + + // #784: the default expression is evaluated lazily (only when undefined) and the matched value is + // read exactly once (a getter is not invoked twice). + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Defaults_LazyAndSingleEvaluation(ExecutionMode mode) + { + var source = """ + let ran = 0; + const side = () => { ran++; return 99; }; + const [p = side()] = [7]; + console.log(p, ran); + const [q = side()] = [undefined]; + console.log(q, ran); + let reads = 0; + const src = { get k() { reads++; return undefined; } }; + const { k = 42 } = src; + console.log(k, reads); + """; + + var output = TestHarness.Run(source, mode); + // p: default not evaluated (value present); q: default evaluated exactly once; k: getter read once. + Assert.Equal("7 0\n99 1\n42 1\n", output); + } + + #endregion + + #region Typed-array Rest (#781) + + // #781: a rest element over a typed-array source must collect a fresh Array (Array.isArray === true), + // not a typed-array slice. Interpreter-only: compiled `new Uint8Array([...])` is blocked by #782. + [Theory] + [MemberData(nameof(ExecutionModes.InterpretedOnly), MemberType = typeof(ExecutionModes))] + public void ArrayDestructuring_TypedArrayRest_BindsFreshArray(ExecutionMode mode) + { + var source = """ + const [a, ...rest] = new Uint8Array([1, 2, 3]); + console.log(a); + console.log(Array.isArray(rest)); + console.log(rest.length); + console.log(rest[0], rest[1]); + """; + + var output = TestHarness.Run(source, mode); + Assert.Equal("1\ntrue\n2\n2 3\n", output); + } + + #endregion + + #region Object Shorthand-with-default Assignment Destructuring (#780) + + // #780: `({ a = 5 } = src)` — the object-pattern cover-grammar form is now accepted by the parser and + // routes through the #754 assignment-destructuring lowering (undefined-only default, #784). + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AssignmentDestructuring_ObjectShorthandDefault(ExecutionMode mode) + { + var source = """ + const present: { a?: number } = { a: 10 }; + let a; ({ a = 5 } = present); + const missing: { b?: number } = {}; + let b; ({ b = 5 } = missing); + console.log(String(a), String(b)); + """; + + var output = TestHarness.Run(source, mode); + Assert.Equal("10 5\n", output); + } + + // #780: the cover-grammar `{ a = 5 }` is only valid as a destructuring pattern; using it as a plain + // object-literal EXPRESSION remains a semantic error, as in tsc. + [Fact] + public void ObjectLiteral_ShorthandDefault_AsExpression_IsTypeError() + { + var source = """ + let a = 1; + const o = { a = 5 }; + console.log(o); + """; + + Assert.ThrowsAny(() => TestHarness.RunInterpreted(source)); + } + + #endregion + + #region Nested Pattern with Default in Assignment Destructuring (#779) + + // #779: a nested array/object pattern that itself carries a default in an ASSIGNMENT destructuring. + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AssignmentDestructuring_NestedArrayPatternWithDefault(ExecutionMode mode) + { + var source = """ + let a; [[a] = [9]] = [[1]]; + let b; [[b] = [9]] = []; + console.log(String(a), String(b)); + """; + + var output = TestHarness.Run(source, mode); + Assert.Equal("1 9\n", output); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AssignmentDestructuring_NestedObjectPatternWithDefault(ExecutionMode mode) + { + var source = """ + const src: { p?: { x: number } } = { p: { x: 1 } }; + let x; ({ p: { x: x } = { x: 7 } } = src); + const empty: { p?: { x: number } } = {}; + let y; ({ p: { x: y } = { x: 7 } } = empty); + console.log(String(x), String(y)); + """; + + var output = TestHarness.Run(source, mode); + Assert.Equal("1 7\n", output); + } + + #endregion } diff --git a/TypeSystem/TypeChecker.Expressions.cs b/TypeSystem/TypeChecker.Expressions.cs index aad760c1..af0a4e0a 100644 --- a/TypeSystem/TypeChecker.Expressions.cs +++ b/TypeSystem/TypeChecker.Expressions.cs @@ -151,6 +151,22 @@ internal TypeInfo VisitDestructuringAssign(Expr.DestructuringAssign expr) { foreach (var stmt in expr.Assignments) CheckStmt(stmt); + + // The lowered spill temps live inside an EXPRESSION, so they escape the whole-body + // numeric-slot taint pass (MarkUndefinedReachableNumericSlots) that the declaration + // desugaring's Stmt.Sequence goes through. A defaulted element/property whose source value + // is absent (`[a = 5] = arr`, `({c = 5} = obj)`) leaves the spill holding the runtime + // `undefined` sentinel; an unboxed `double` slot would coerce it to NaN at the store. Widen + // every synthesized temp back to an object slot (a no-op for the non-numeric source temps). #784 + foreach (var stmt in expr.Assignments) + { + if (stmt is Stmt.Var { Initializer: { } init } varStmt) + { + _typeMap.MarkUndefinedReachableNumericLocal(varStmt); + _typeMap.MarkUndefinedReachableNumericLocal(init); + } + } + return CheckExpr(expr.ResultValue); } @@ -686,6 +702,16 @@ private TypeInfo CheckObject(Expr.ObjectLiteral obj) } else { + if (prop.IsShorthandDefault) + { + // A `{ a = 5 }` CoverInitializedName reaching CheckObject was used as a plain object + // literal expression, not a destructuring target (assignment-destructuring patterns are + // consumed by BuildDestructuringAssignment before type-checking). tsc rejects it (#780). + throw new TypeCheckException( + " '=' can only be used in an object literal property inside a destructuring assignment.", + tsCode: "TS1312"); + } + TypeInfo valueType = CheckExpr(prop.Value); switch (prop.Key)