diff --git a/Compilation/AsyncArrowMoveNextEmitter.Variables.cs b/Compilation/AsyncArrowMoveNextEmitter.Variables.cs index a2e46190..e1a1c31d 100644 --- a/Compilation/AsyncArrowMoveNextEmitter.Variables.cs +++ b/Compilation/AsyncArrowMoveNextEmitter.Variables.cs @@ -6,8 +6,21 @@ namespace SharpTS.Compilation; public partial class AsyncArrowMoveNextEmitter { + // Per-binding storage names for block-scoped let/const shadows (#766), shared with the analyzer via + // the analysis. Empty for the common no-shadow case (and for expression-bodied arrows). + private static readonly IReadOnlyDictionary NoRenames = new Dictionary(); + private IReadOnlyDictionary BlockScopeRenames => _analysis.BlockScopeRenames ?? NoRenames; + + private static Token RenameToken(Token original, string lexeme) => + new(original.Type, lexeme, original.Literal, original.Line, original.Start); + protected override void EmitVariable(Expr.Variable v) { + // Resolve a shadowing block-scoped binding to its own storage before resolution (#766); a renamed + // binding is never a captured/DC name, so the capture checks below correctly fall through. + if (BlockScopeRenames.TryGetValue(v, out var renamed)) + v = v with { Name = RenameToken(v.Name, renamed) }; + string name = v.Name.Lexeme; // A capture promoted into the enclosing function's display class (#625) is read through @@ -90,6 +103,9 @@ protected override void EmitVariable(Expr.Variable v) protected override void EmitAssign(Expr.Assign a) { + if (BlockScopeRenames.TryGetValue(a, out var renamed)) + a = a with { Name = RenameToken(a.Name, renamed) }; + string name = a.Name.Lexeme; EmitExpression(a.Value); @@ -354,4 +370,45 @@ private void EmitLoadOuterFunctionDC() _il.Emit(OpCodes.Unbox, _builder.OuterStateMachineType!); _il.Emit(OpCodes.Ldfld, _ctx!.OuterFunctionDCField!); } + + // Const declarations, compound/logical assignment, and increment/decrement reach the variable + // through the operator node's name token (or the increment operand). Rewriting that token to the + // shadowing binding's storage name before delegating to the base routes both the read and the + // write to the right field/local (#766). The base re-enters EmitVarDeclaration / EmitVariable / + // EmitStoreVariable (all of which resolve by name here), so the rename flows through consistently. + + protected override void EmitConstDeclaration(Stmt.Const c) + { + if (BlockScopeRenames.TryGetValue(c, out var renamed)) + c = c with { Name = RenameToken(c.Name, renamed) }; + base.EmitConstDeclaration(c); + } + + protected override void EmitCompoundAssign(Expr.CompoundAssign ca) + { + if (BlockScopeRenames.TryGetValue(ca, out var renamed)) + ca = ca with { Name = RenameToken(ca.Name, renamed) }; + base.EmitCompoundAssign(ca); + } + + protected override void EmitLogicalAssign(Expr.LogicalAssign la) + { + if (BlockScopeRenames.TryGetValue(la, out var renamed)) + la = la with { Name = RenameToken(la.Name, renamed) }; + base.EmitLogicalAssign(la); + } + + protected override void EmitPrefixIncrement(Expr.PrefixIncrement pi) + { + if (pi.Operand is Expr.Variable v && BlockScopeRenames.TryGetValue(v, out var renamed)) + pi = pi with { Operand = v with { Name = RenameToken(v.Name, renamed) } }; + base.EmitPrefixIncrement(pi); + } + + protected override void EmitPostfixIncrement(Expr.PostfixIncrement poi) + { + if (poi.Operand is Expr.Variable v && BlockScopeRenames.TryGetValue(v, out var renamed)) + poi = poi with { Operand = v with { Name = RenameToken(v.Name, renamed) } }; + base.EmitPostfixIncrement(poi); + } } diff --git a/Compilation/AsyncArrowMoveNextEmitter.cs b/Compilation/AsyncArrowMoveNextEmitter.cs index 1d32047a..96909326 100644 --- a/Compilation/AsyncArrowMoveNextEmitter.cs +++ b/Compilation/AsyncArrowMoveNextEmitter.cs @@ -270,6 +270,10 @@ protected override void RegisterLoopLocal(string name, LocalBuilder local) protected override void EmitVarDeclaration(Stmt.Var v) { + // Route a shadowing block-scoped binding to its own storage (#766). + if (BlockScopeRenames.TryGetValue(v, out var renamed)) + v = v with { Name = RenameToken(v.Name, renamed) }; + if (v.Initializer != null) { EmitExpression(v.Initializer); diff --git a/Compilation/AsyncGeneratorMoveNextEmitter.Variables.cs b/Compilation/AsyncGeneratorMoveNextEmitter.Variables.cs index 1cc8369d..54ee6c6d 100644 --- a/Compilation/AsyncGeneratorMoveNextEmitter.Variables.cs +++ b/Compilation/AsyncGeneratorMoveNextEmitter.Variables.cs @@ -9,6 +9,14 @@ public partial class AsyncGeneratorMoveNextEmitter // capturing arrow inside the async generator body gets its $functionDC threaded in (#725). protected override FieldBuilder? GetFunctionDCField() => _builder.FunctionDCField; + // Per-binding storage names for block-scoped let/const shadows (#766), shared with the analyzer via + // the analysis. Empty for the common no-shadow case (and for analyses built without the renamer). + private static readonly IReadOnlyDictionary NoRenames = new Dictionary(); + private IReadOnlyDictionary BlockScopeRenames => _analysis.BlockScopeRenames ?? NoRenames; + + private static Token RenameToken(Token original, string lexeme) => + new(original.Type, lexeme, original.Literal, original.Line, original.Start); + /// /// Routes reads and writes of a captured-AND-mutated async-generator local/parameter through the /// shared function display class (#725) so a write inside an arrow/callback and the generator body @@ -42,6 +50,11 @@ private void StoreToDCField(FieldBuilder dcField) protected override void EmitVariable(Expr.Variable v) { + // Resolve a shadowing block-scoped binding to its own storage before any DC routing (#766); + // a renamed binding is never a captured/DC name, so the DC check below correctly falls through. + if (BlockScopeRenames.TryGetValue(v, out var renamed)) + v = v with { Name = RenameToken(v.Name, renamed) }; + if (TryGetFunctionDCField(v.Name.Lexeme, out var dcField)) { _il.Emit(OpCodes.Ldarg_0); @@ -56,6 +69,9 @@ protected override void EmitVariable(Expr.Variable v) protected override void EmitVarDeclaration(Stmt.Var v) { + if (BlockScopeRenames.TryGetValue(v, out var renamed)) + v = v with { Name = RenameToken(v.Name, renamed) }; + if (TryGetFunctionDCField(v.Name.Lexeme, out var dcField)) { if (v.Initializer != null) @@ -76,6 +92,9 @@ protected override void EmitVarDeclaration(Stmt.Var v) protected override void EmitAssign(Expr.Assign a) { + if (BlockScopeRenames.TryGetValue(a, out var renamed)) + a = a with { Name = RenameToken(a.Name, renamed) }; + if (TryGetFunctionDCField(a.Name.Lexeme, out var dcField)) { EmitExpression(a.Value); @@ -104,4 +123,45 @@ protected override void EmitStoreVariable(string name) base.EmitStoreVariable(name); } + + // Const declarations, compound/logical assignment, and increment/decrement reach the variable + // through the operator node's name token (or the increment operand). Rewriting that token to the + // shadowing binding's storage name before delegating to the base routes both the read and the + // write to the right field/local (#766). A renamed binding is never a DC/captured name, so the + // base path (which re-enters EmitVariable / EmitStoreVariable) resolves it as a plain slot. + + protected override void EmitConstDeclaration(Stmt.Const c) + { + if (BlockScopeRenames.TryGetValue(c, out var renamed)) + c = c with { Name = RenameToken(c.Name, renamed) }; + base.EmitConstDeclaration(c); + } + + protected override void EmitCompoundAssign(Expr.CompoundAssign ca) + { + if (BlockScopeRenames.TryGetValue(ca, out var renamed)) + ca = ca with { Name = RenameToken(ca.Name, renamed) }; + base.EmitCompoundAssign(ca); + } + + protected override void EmitLogicalAssign(Expr.LogicalAssign la) + { + if (BlockScopeRenames.TryGetValue(la, out var renamed)) + la = la with { Name = RenameToken(la.Name, renamed) }; + base.EmitLogicalAssign(la); + } + + protected override void EmitPrefixIncrement(Expr.PrefixIncrement pi) + { + if (pi.Operand is Expr.Variable v && BlockScopeRenames.TryGetValue(v, out var renamed)) + pi = pi with { Operand = v with { Name = RenameToken(v.Name, renamed) } }; + base.EmitPrefixIncrement(pi); + } + + protected override void EmitPostfixIncrement(Expr.PostfixIncrement poi) + { + if (poi.Operand is Expr.Variable v && BlockScopeRenames.TryGetValue(v, out var renamed)) + poi = poi with { Operand = v with { Name = RenameToken(v.Name, renamed) } }; + base.EmitPostfixIncrement(poi); + } } diff --git a/Compilation/AsyncGeneratorStateAnalyzer.Expressions.cs b/Compilation/AsyncGeneratorStateAnalyzer.Expressions.cs index f538c1bb..01286ff6 100644 --- a/Compilation/AsyncGeneratorStateAnalyzer.Expressions.cs +++ b/Compilation/AsyncGeneratorStateAnalyzer.Expressions.cs @@ -88,7 +88,8 @@ protected override void VisitAwait(Expr.Await expr) protected override void VisitVariable(Expr.Variable expr) { - var name = expr.Name.Lexeme; + // Resolve to the binding's (possibly disambiguated) storage name (#766). + var name = StorageName(expr, expr.Name.Lexeme); // Track variables used in for...of loop bodies (for hoisting when loop contains suspension) foreach (var loop in _forOfStack) @@ -104,22 +105,25 @@ protected override void VisitVariable(Expr.Variable expr) protected override void VisitAssign(Expr.Assign expr) { - if (_seenSuspension && _declaredVariables.Contains(expr.Name.Lexeme)) - _variablesUsedAfterSuspension.Add(expr.Name.Lexeme); + var name = StorageName(expr, expr.Name.Lexeme); + if (_seenSuspension && _declaredVariables.Contains(name)) + _variablesUsedAfterSuspension.Add(name); base.VisitAssign(expr); } protected override void VisitCompoundAssign(Expr.CompoundAssign expr) { - if (_seenSuspension && _declaredVariables.Contains(expr.Name.Lexeme)) - _variablesUsedAfterSuspension.Add(expr.Name.Lexeme); + var name = StorageName(expr, expr.Name.Lexeme); + if (_seenSuspension && _declaredVariables.Contains(name)) + _variablesUsedAfterSuspension.Add(name); base.VisitCompoundAssign(expr); } protected override void VisitLogicalAssign(Expr.LogicalAssign expr) { - if (_seenSuspension && _declaredVariables.Contains(expr.Name.Lexeme)) - _variablesUsedAfterSuspension.Add(expr.Name.Lexeme); + var name = StorageName(expr, expr.Name.Lexeme); + if (_seenSuspension && _declaredVariables.Contains(name)) + _variablesUsedAfterSuspension.Add(name); base.VisitLogicalAssign(expr); } diff --git a/Compilation/AsyncGeneratorStateAnalyzer.Statements.cs b/Compilation/AsyncGeneratorStateAnalyzer.Statements.cs index fe5a1587..eec53008 100644 --- a/Compilation/AsyncGeneratorStateAnalyzer.Statements.cs +++ b/Compilation/AsyncGeneratorStateAnalyzer.Statements.cs @@ -8,17 +8,21 @@ public partial class AsyncGeneratorStateAnalyzer protected override void VisitVar(Stmt.Var stmt) { - _declaredVariables.Add(stmt.Name.Lexeme); + // Track declaration under its (possibly disambiguated) storage name (#766). + var name = StorageName(stmt, stmt.Name.Lexeme); + _declaredVariables.Add(name); if (!_seenSuspension) - _variablesDeclaredBeforeSuspension.Add(stmt.Name.Lexeme); + _variablesDeclaredBeforeSuspension.Add(name); base.VisitVar(stmt); } protected override void VisitConst(Stmt.Const stmt) { - _declaredVariables.Add(stmt.Name.Lexeme); + // Track declaration under its (possibly disambiguated) storage name (#766). + var name = StorageName(stmt, stmt.Name.Lexeme); + _declaredVariables.Add(name); if (!_seenSuspension) - _variablesDeclaredBeforeSuspension.Add(stmt.Name.Lexeme); + _variablesDeclaredBeforeSuspension.Add(name); base.VisitConst(stmt); } diff --git a/Compilation/AsyncGeneratorStateAnalyzer.cs b/Compilation/AsyncGeneratorStateAnalyzer.cs index db95f228..d1f41e1d 100644 --- a/Compilation/AsyncGeneratorStateAnalyzer.cs +++ b/Compilation/AsyncGeneratorStateAnalyzer.cs @@ -53,7 +53,10 @@ public record AsyncGeneratorFunctionAnalysis( bool HasYieldStar, bool HasTryCatch, List TryBlocks, - List ForOfLoopsWithSuspension // for...of loops containing yields/awaits that need enumerator hoisting + List ForOfLoopsWithSuspension, // for...of loops containing yields/awaits that need enumerator hoisting + // Per-binding storage names for block-scoped let/const declarations that shadow an enclosing + // binding, keyed by declaration/reference AST node (see GeneratorBlockScopeRenamer, #766/#711). + IReadOnlyDictionary? BlockScopeRenames = null ) { /// @@ -104,6 +107,17 @@ private enum TryRegion { None, Try, Catch, Finally } // Reusable visitor for analyzing captures private readonly CaptureAnalysisVisitor _captureVisitor = new(); + // Block-scope shadow renames for this function (#766). Maps a declaration/reference AST node to the + // disambiguated storage name its binding uses; nodes absent from the map keep their source lexeme. + private IReadOnlyDictionary _renames = new Dictionary(); + + /// + /// Translates a declaration/reference node's source lexeme to its disambiguated storage name (#766), + /// or returns the lexeme unchanged when the binding is not a renamed shadow. + /// + private string StorageName(object node, string lexeme) => + _renames.TryGetValue(node, out var renamed) ? renamed : lexeme; + /// /// Analyzes an async generator function to determine suspension points and hoisted variables. /// @@ -111,6 +125,10 @@ public AsyncGeneratorFunctionAnalysis Analyze(Stmt.Function func) { Reset(); + // Disambiguate block-scoped let/const declarations that shadow an enclosing binding so the + // hoisting decision below is made per-binding rather than per-name (#766, async analog of #711). + _renames = GeneratorBlockScopeRenamer.Compute(func); + // Collect parameters as variables that need hoisting HashSet parameters = []; foreach (var param in func.Parameters) @@ -147,7 +165,8 @@ public AsyncGeneratorFunctionAnalysis Analyze(Stmt.Function func) HasYieldStar: _hasYieldStar, HasTryCatch: _hasTryCatch, TryBlocks: tryBlocks, - ForOfLoopsWithSuspension: [.. _forOfLoopsWithSuspension] + ForOfLoopsWithSuspension: [.. _forOfLoopsWithSuspension], + BlockScopeRenames: _renames ); } diff --git a/Compilation/AsyncMoveNextEmitter.Statements.Variables.cs b/Compilation/AsyncMoveNextEmitter.Statements.Variables.cs index 47658fb5..c79f4fda 100644 --- a/Compilation/AsyncMoveNextEmitter.Statements.Variables.cs +++ b/Compilation/AsyncMoveNextEmitter.Statements.Variables.cs @@ -7,6 +7,14 @@ public partial class AsyncMoveNextEmitter { protected override FieldBuilder? GetHoistedVariableField(string name) => _builder.GetVariableField(name); + // Per-binding storage names for block-scoped let/const shadows (#766), shared with the analyzer via + // the analysis. Empty for the common no-shadow case (and for analyses built without the renamer). + private static readonly IReadOnlyDictionary NoRenames = new Dictionary(); + private IReadOnlyDictionary BlockScopeRenames => _analysis.BlockScopeRenames ?? NoRenames; + + private static Token RenameToken(Token original, string lexeme) => + new(original.Type, lexeme, original.Literal, original.Line, original.Start); + /// /// Checks whether a variable is a captured function local with a function DC field available. /// @@ -40,6 +48,11 @@ private void StoreToDCField(string name, FieldBuilder dcField) /// protected override void EmitVarDeclaration(Stmt.Var v) { + // Resolve a shadowing block-scoped binding to its own storage before any DC routing (#766); + // a renamed binding is never a captured/DC name, so the DC check below correctly falls through. + if (BlockScopeRenames.TryGetValue(v, out var renamed)) + v = v with { Name = RenameToken(v.Name, renamed) }; + string name = v.Name.Lexeme; if (TryGetFunctionDCField(name, out var dcField)) @@ -83,6 +96,9 @@ protected override void EmitVarDeclaration(Stmt.Var v) /// protected override void EmitVariable(Expr.Variable v) { + if (BlockScopeRenames.TryGetValue(v, out var renamed)) + v = v with { Name = RenameToken(v.Name, renamed) }; + string name = v.Name.Lexeme; if (TryGetFunctionDCField(name, out var dcField)) @@ -102,6 +118,9 @@ protected override void EmitVariable(Expr.Variable v) /// protected override void EmitAssign(Expr.Assign a) { + if (BlockScopeRenames.TryGetValue(a, out var renamed)) + a = a with { Name = RenameToken(a.Name, renamed) }; + string name = a.Name.Lexeme; if (TryGetFunctionDCField(name, out var dcField)) @@ -165,4 +184,45 @@ protected override void EmitStoreVariable(string name) base.EmitStoreVariable(name); } + + // Const declarations, compound/logical assignment, and increment/decrement reach the variable + // through the operator node's name token (or the increment operand). Rewriting that token to the + // shadowing binding's storage name before delegating to the base routes both the read and the + // write to the right field/local (#766). A renamed binding is never a DC/captured name, so the + // base path (which re-enters EmitVariable / EmitStoreVariable) resolves it as a plain slot. + + protected override void EmitConstDeclaration(Stmt.Const c) + { + if (BlockScopeRenames.TryGetValue(c, out var renamed)) + c = c with { Name = RenameToken(c.Name, renamed) }; + base.EmitConstDeclaration(c); + } + + protected override void EmitCompoundAssign(Expr.CompoundAssign ca) + { + if (BlockScopeRenames.TryGetValue(ca, out var renamed)) + ca = ca with { Name = RenameToken(ca.Name, renamed) }; + base.EmitCompoundAssign(ca); + } + + protected override void EmitLogicalAssign(Expr.LogicalAssign la) + { + if (BlockScopeRenames.TryGetValue(la, out var renamed)) + la = la with { Name = RenameToken(la.Name, renamed) }; + base.EmitLogicalAssign(la); + } + + protected override void EmitPrefixIncrement(Expr.PrefixIncrement pi) + { + if (pi.Operand is Expr.Variable v && BlockScopeRenames.TryGetValue(v, out var renamed)) + pi = pi with { Operand = v with { Name = RenameToken(v.Name, renamed) } }; + base.EmitPrefixIncrement(pi); + } + + protected override void EmitPostfixIncrement(Expr.PostfixIncrement poi) + { + if (poi.Operand is Expr.Variable v && BlockScopeRenames.TryGetValue(v, out var renamed)) + poi = poi with { Operand = v with { Name = RenameToken(v.Name, renamed) } }; + base.EmitPostfixIncrement(poi); + } } diff --git a/Compilation/AsyncStateAnalyzer.Expressions.cs b/Compilation/AsyncStateAnalyzer.Expressions.cs index 66e2f09f..db3f4454 100644 --- a/Compilation/AsyncStateAnalyzer.Expressions.cs +++ b/Compilation/AsyncStateAnalyzer.Expressions.cs @@ -53,31 +53,35 @@ internal void RecordAwaitPoint(Expr.Await? expr) protected override void VisitVariable(Expr.Variable expr) { - // Track variable usage after await - if (_seenAwait && _declaredVariables.Contains(expr.Name.Lexeme)) - _variablesUsedAfterAwait.Add(expr.Name.Lexeme); + // Track variable usage after await, under its (possibly disambiguated) storage name (#766). + var name = StorageName(expr, expr.Name.Lexeme); + if (_seenAwait && _declaredVariables.Contains(name)) + _variablesUsedAfterAwait.Add(name); // No base call needed - leaf node } protected override void VisitAssign(Expr.Assign expr) { - // Track assignment to variable after await - if (_seenAwait && _declaredVariables.Contains(expr.Name.Lexeme)) - _variablesUsedAfterAwait.Add(expr.Name.Lexeme); + // Track assignment to variable after await, under its storage name (#766). + var name = StorageName(expr, expr.Name.Lexeme); + if (_seenAwait && _declaredVariables.Contains(name)) + _variablesUsedAfterAwait.Add(name); base.VisitAssign(expr); } protected override void VisitCompoundAssign(Expr.CompoundAssign expr) { - if (_seenAwait && _declaredVariables.Contains(expr.Name.Lexeme)) - _variablesUsedAfterAwait.Add(expr.Name.Lexeme); + var name = StorageName(expr, expr.Name.Lexeme); + if (_seenAwait && _declaredVariables.Contains(name)) + _variablesUsedAfterAwait.Add(name); base.VisitCompoundAssign(expr); } protected override void VisitLogicalAssign(Expr.LogicalAssign expr) { - if (_seenAwait && _declaredVariables.Contains(expr.Name.Lexeme)) - _variablesUsedAfterAwait.Add(expr.Name.Lexeme); + var name = StorageName(expr, expr.Name.Lexeme); + if (_seenAwait && _declaredVariables.Contains(name)) + _variablesUsedAfterAwait.Add(name); base.VisitLogicalAssign(expr); } diff --git a/Compilation/AsyncStateAnalyzer.Statements.cs b/Compilation/AsyncStateAnalyzer.Statements.cs index d8e4b743..6f1a2590 100644 --- a/Compilation/AsyncStateAnalyzer.Statements.cs +++ b/Compilation/AsyncStateAnalyzer.Statements.cs @@ -8,20 +8,22 @@ public partial class AsyncStateAnalyzer protected override void VisitVar(Stmt.Var stmt) { - // Track variable declaration - _declaredVariables.Add(stmt.Name.Lexeme); + // Track variable declaration under its (possibly disambiguated) storage name (#766). + var name = StorageName(stmt, stmt.Name.Lexeme); + _declaredVariables.Add(name); if (!_seenAwait) - _variablesDeclaredBeforeAwait.Add(stmt.Name.Lexeme); + _variablesDeclaredBeforeAwait.Add(name); base.VisitVar(stmt); } protected override void VisitConst(Stmt.Const stmt) { - // Track const variable declaration - _declaredVariables.Add(stmt.Name.Lexeme); + // Track const variable declaration under its (possibly disambiguated) storage name (#766). + var name = StorageName(stmt, stmt.Name.Lexeme); + _declaredVariables.Add(name); if (!_seenAwait) - _variablesDeclaredBeforeAwait.Add(stmt.Name.Lexeme); + _variablesDeclaredBeforeAwait.Add(name); base.VisitConst(stmt); } diff --git a/Compilation/AsyncStateAnalyzer.cs b/Compilation/AsyncStateAnalyzer.cs index 1e4b7528..730bc38b 100644 --- a/Compilation/AsyncStateAnalyzer.cs +++ b/Compilation/AsyncStateAnalyzer.cs @@ -55,7 +55,11 @@ public record AsyncFunctionAnalysis( bool HasTryCatch, bool UsesThis, List AsyncArrows, - List TryBlocks = null! // Try blocks with await tracking + List TryBlocks = null!, // Try blocks with await tracking + // Per-binding storage names for block-scoped let/const declarations that shadow an enclosing + // binding, keyed by declaration/reference AST node (see GeneratorBlockScopeRenamer, #766/#711). + // Null for analyses built without the renamer (e.g. async arrows); treated as empty. + IReadOnlyDictionary? BlockScopeRenames = null ) { /// @@ -97,6 +101,17 @@ private enum TryRegion { None, Try, Catch, Finally } // Reusable visitor for analyzing arrow function captures private readonly CaptureAnalysisVisitor _captureVisitor = new(); + // Block-scope shadow renames for this function (#766). Maps a declaration/reference AST node to the + // disambiguated storage name its binding uses; nodes absent from the map keep their source lexeme. + private IReadOnlyDictionary _renames = new Dictionary(); + + /// + /// Translates a declaration/reference node's source lexeme to its disambiguated storage name (#766), + /// or returns the lexeme unchanged when the binding is not a renamed shadow. + /// + private string StorageName(object node, string lexeme) => + _renames.TryGetValue(node, out var renamed) ? renamed : lexeme; + /// /// Analyzes an async function to determine await points and hoisted variables. /// @@ -104,6 +119,10 @@ public AsyncFunctionAnalysis Analyze(Stmt.Function func) { Reset(); + // Disambiguate block-scoped let/const declarations that shadow an enclosing binding so the + // hoisting decision below is made per-binding rather than per-name (#766, async analog of #711). + _renames = GeneratorBlockScopeRenamer.Compute(func); + // Collect parameters as variables that need hoisting HashSet parameters = []; foreach (var param in func.Parameters) @@ -140,7 +159,8 @@ public AsyncFunctionAnalysis Analyze(Stmt.Function func) HasTryCatch: _hasTryCatch, UsesThis: _usesThis, AsyncArrows: [.. _asyncArrows], - TryBlocks: tryBlocks + TryBlocks: tryBlocks, + BlockScopeRenames: _renames ); } diff --git a/Compilation/GeneratorBlockScopeRenamer.cs b/Compilation/GeneratorBlockScopeRenamer.cs index 99ec94a0..4e6ae32a 100644 --- a/Compilation/GeneratorBlockScopeRenamer.cs +++ b/Compilation/GeneratorBlockScopeRenamer.cs @@ -5,9 +5,13 @@ namespace SharpTS.Compilation; /// /// Computes per-binding storage names for block-scoped (let/const) declarations inside a -/// generator body that shadow an enclosing binding of the same name. +/// suspension state-machine body that shadow an enclosing binding of the same name. /// /// +/// Although named for generators (where it originated, #711), this pass is mode-agnostic: every +/// suspension state machine that hoists live-across-suspension locals to name-keyed fields shares the +/// same shadow-collision root cause, so it is reused by the async-function and async-generator state +/// machines too ( / , #766). /// The generator state machine hoists every local that lives across a yield into a field keyed /// by its source name (see ), and resolves non-hoisted locals through a /// flat, name-keyed local map. Both flatten lexical scope, so two block-scoped bindings that share a @@ -45,23 +49,35 @@ internal sealed class GeneratorBlockScopeRenamer : AstVisitorBase /// /// Returns the node→storage-name map for , empty when nothing shadows. /// - public static IReadOnlyDictionary Compute(Stmt.Function func) + public static IReadOnlyDictionary Compute(Stmt.Function func) => + Compute(func.Name.Lexeme, func.Parameters, func.Body); + + /// + /// Arrow-function overload (#766). Arrows have no self-name, and only a block body can hold + /// block-scoped declarations (an expression-bodied arrow has none, so it has no shadows to rename). + /// + public static IReadOnlyDictionary Compute(Expr.ArrowFunction arrow) => + Compute(selfName: null, arrow.Parameters, arrow.BlockBody); + + private static IReadOnlyDictionary Compute( + string? selfName, List parameters, List? body) { var renamer = new GeneratorBlockScopeRenamer(); - new ClosureReferenceCollector(renamer._closureReferenced).Run(func); + if (body == null) return renamer._renames; // expression-bodied arrow: nothing to rename + + new ClosureReferenceCollector(renamer._closureReferenced).Run(body); renamer.PushScope(); - // The generator's own name is in scope inside the body (a named function expression can call + // The callable's own name is in scope inside the body (a named function expression can call // itself by it); a nested-block let/const may shadow it, so seed it for shadow detection. // Never renamed (it is not a hoisted local — it resolves to the function/method itself). - if (!string.IsNullOrEmpty(func.Name.Lexeme)) - renamer.CurrentScope[func.Name.Lexeme] = func.Name.Lexeme; + if (!string.IsNullOrEmpty(selfName)) + renamer.CurrentScope[selfName] = selfName; // Parameters live in the function scope; an inner block let/const may shadow them. Never renamed. - foreach (var p in func.Parameters) + foreach (var p in parameters) renamer.CurrentScope[p.Name.Lexeme] = p.Name.Lexeme; - if (func.Body != null) - foreach (var stmt in func.Body) - renamer.Visit(stmt); + foreach (var stmt in body) + renamer.Visit(stmt); renamer.PopScope(); return renamer._renames; @@ -256,10 +272,9 @@ private sealed class ClosureReferenceCollector : AstVisitorBase public ClosureReferenceCollector(HashSet names) => _names = names; - public void Run(Stmt.Function func) + public void Run(List body) { - if (func.Body != null) - foreach (var s in func.Body) Visit(s); + foreach (var s in body) Visit(s); } protected override void VisitArrowFunction(Expr.ArrowFunction expr) { _depth++; base.VisitArrowFunction(expr); _depth--; } diff --git a/Compilation/ILCompiler.Async.cs b/Compilation/ILCompiler.Async.cs index 594dfd71..898f9e3f 100644 --- a/Compilation/ILCompiler.Async.cs +++ b/Compilation/ILCompiler.Async.cs @@ -276,11 +276,25 @@ private void DefineAsyncArrowStateMachines( /// Analyzes an async arrow function to determine its await points and hoisted variables. /// Uses pooled HashSets for intermediate analysis to reduce allocations. /// - private (int AwaitCount, HashSet HoistedLocals) AnalyzeAsyncArrow(Expr.ArrowFunction arrow) + // Block-scope shadow renames for the async arrow currently being analyzed (#766). Set at the top of + // AnalyzeAsyncArrow and consulted by the AnalyzeArrow*ForAwaits walkers, which are non-reentrant (a + // nested arrow is a separate AnalyzeAsyncArrow pass — the Expr.ArrowFunction walker case is a no-op, + // so this field is never clobbered mid-walk). Mirrors the renamer wiring in the visitor analyzers. + private IReadOnlyDictionary _arrowBlockScopeRenames = new Dictionary(); + + /// Storage name for an async-arrow declaration/reference node (#766), or the lexeme unchanged. + private string ArrowStorageName(object node, string lexeme) => + _arrowBlockScopeRenames.TryGetValue(node, out var renamed) ? renamed : lexeme; + + private (int AwaitCount, HashSet HoistedLocals, IReadOnlyDictionary Renames) AnalyzeAsyncArrow(Expr.ArrowFunction arrow) { var awaitCount = 0; var seenAwait = false; + // Disambiguate nested-block let/const shadows so the hoisting decision below is per-binding + // rather than per-name (#766, async analog of #711). + _arrowBlockScopeRenames = GeneratorBlockScopeRenamer.Compute(arrow); + // Clear and reuse pooled HashSets _async.DeclaredVars.Clear(); _async.UsedAfterAwait.Clear(); @@ -313,11 +327,12 @@ private void DefineAsyncArrowStateMachines( var hoistedLocals = new HashSet(_async.DeclaredBeforeAwait); hoistedLocals.IntersectWith(_async.UsedAfterAwait); - // Remove parameters from hoisted locals (they're stored separately) + // Remove parameters from hoisted locals (they're stored separately). Parameters are never + // renamed, so removing by source lexeme is correct. foreach (var param in arrow.Parameters) hoistedLocals.Remove(param.Name.Lexeme); - return (awaitCount, hoistedLocals); + return (awaitCount, hoistedLocals, _arrowBlockScopeRenames); } private void AnalyzeArrowStmtForAwaits(Stmt stmt, ref int awaitCount, ref bool seenAwait, @@ -326,18 +341,24 @@ private void AnalyzeArrowStmtForAwaits(Stmt stmt, ref int awaitCount, ref bool s switch (stmt) { case Stmt.Var v: - declaredVariables.Add(v.Name.Lexeme); + { + var name = ArrowStorageName(v, v.Name.Lexeme); // #766: per-binding storage name + declaredVariables.Add(name); if (!seenAwait) - declaredBeforeAwait.Add(v.Name.Lexeme); + declaredBeforeAwait.Add(name); if (v.Initializer != null) AnalyzeArrowExprForAwaits(v.Initializer, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); break; + } case Stmt.Const c: - declaredVariables.Add(c.Name.Lexeme); + { + var name = ArrowStorageName(c, c.Name.Lexeme); // #766: per-binding storage name + declaredVariables.Add(name); if (!seenAwait) - declaredBeforeAwait.Add(c.Name.Lexeme); + declaredBeforeAwait.Add(name); AnalyzeArrowExprForAwaits(c.Initializer, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); break; + } case Stmt.Expression e: AnalyzeArrowExprForAwaits(e.Expr, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); break; @@ -429,14 +450,20 @@ private void AnalyzeArrowExprForAwaits(Expr expr, ref int awaitCount, ref bool s AnalyzeArrowExprForAwaits(a.Expression, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); break; case Expr.Variable v: - if (seenAwait && declaredVariables.Contains(v.Name.Lexeme)) - usedAfterAwait.Add(v.Name.Lexeme); + { + var name = ArrowStorageName(v, v.Name.Lexeme); // #766 + if (seenAwait && declaredVariables.Contains(name)) + usedAfterAwait.Add(name); break; + } case Expr.Assign a: - if (seenAwait && declaredVariables.Contains(a.Name.Lexeme)) - usedAfterAwait.Add(a.Name.Lexeme); + { + var name = ArrowStorageName(a, a.Name.Lexeme); // #766 + if (seenAwait && declaredVariables.Contains(name)) + usedAfterAwait.Add(name); AnalyzeArrowExprForAwaits(a.Value, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); break; + } case Expr.Binary b: AnalyzeArrowExprForAwaits(b.Left, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); AnalyzeArrowExprForAwaits(b.Right, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); @@ -506,10 +533,13 @@ private void AnalyzeArrowExprForAwaits(Expr expr, ref int awaitCount, ref bool s AnalyzeArrowExprForAwaits(e, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); break; case Expr.CompoundAssign ca: - if (seenAwait && declaredVariables.Contains(ca.Name.Lexeme)) - usedAfterAwait.Add(ca.Name.Lexeme); + { + var name = ArrowStorageName(ca, ca.Name.Lexeme); // #766 + if (seenAwait && declaredVariables.Contains(name)) + usedAfterAwait.Add(name); AnalyzeArrowExprForAwaits(ca.Value, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); break; + } case Expr.CompoundSet cs: AnalyzeArrowExprForAwaits(cs.Object, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); AnalyzeArrowExprForAwaits(cs.Value, ref awaitCount, ref seenAwait, declaredVariables, usedAfterAwait, declaredBeforeAwait); @@ -677,7 +707,10 @@ private void EmitAsyncArrowMoveNext(AsyncArrowStateMachineBuilder arrowBuilder, new HashSet(arrow.Parameters.Select(p => p.Name.Lexeme)), false, // HasTryCatch - will be detected during emission arrowBuilder.Captures.Contains("this"), - [] // No nested async arrows handled yet + [], // No nested async arrows handled yet + // #766: carry the block-scope shadow renames so the arrow emitter routes a shadowing + // nested-block let/const to its own storage instead of the outer binding's hoisted field. + BlockScopeRenames: arrowAnalysis.Renames ); // Create a new context for arrow MoveNext emission @@ -1174,7 +1207,9 @@ private void EmitTopLevelAsyncArrowBodies() new HashSet(arrow.Parameters.Select(p => p.Name.Lexeme)), false, // HasTryCatch - detected during emission false, // UsesThis - standalone arrows don't have 'this' binding by default - [] // No nested async arrows handled in this pass + [], // No nested async arrows handled in this pass + // #766: carry the block-scope shadow renames into the standalone arrow emitter too. + BlockScopeRenames: arrowAnalysis.Renames ); // Determine if this arrow is inside a class (for private field access) diff --git a/SharpTS.Tests/Compilation/EmitterSyncTests.cs b/SharpTS.Tests/Compilation/EmitterSyncTests.cs index ee6ed891..5b2f21ec 100644 --- a/SharpTS.Tests/Compilation/EmitterSyncTests.cs +++ b/SharpTS.Tests/Compilation/EmitterSyncTests.cs @@ -58,10 +58,18 @@ public class EmitterSyncTests "EmitTemplateLiteral", // Template literals with await-safe temp storage "EmitTaggedTemplateLiteral", // Tagged template literals in async context // --- Closure mutation sharing --- - "EmitVariable", // Route captured function locals through function DC - "EmitAssign", // Route captured function locals through function DC + "EmitVariable", // Route captured function locals through function DC (+ #766 rename) + "EmitAssign", // Route captured function locals through function DC (+ #766 rename) "EmitStoreVariable", // Route captured function locals through function DC - "EmitVarDeclaration", // Route captured function locals through function DC + "EmitVarDeclaration", // Route captured function locals through function DC (+ #766 rename) + // --- #766: a nested-block let/const that shadows an enclosing binding gets its own storage; + // these overrides retoken the operator node so the read/write land on the shadow's own + // field/local instead of the outer binding's hoisted field (async analog of #711) --- + "EmitConstDeclaration", // #766: route a shadowing const declaration to its own slot + "EmitCompoundAssign", // #766: route a shadowing compound assignment to its own slot + "EmitLogicalAssign", // #766: route a shadowing logical assignment to its own slot + "EmitPrefixIncrement", // #766: route a shadowing prefix ++/-- to its own slot + "EmitPostfixIncrement", // #766: route a shadowing postfix ++/-- to its own slot }, [typeof(AsyncArrowMoveNextEmitter)] = new() { @@ -92,6 +100,15 @@ public class EmitterSyncTests "EmitAwait", // Core: suspend/resume state machine "EmitArrowFunction", // Nested async arrows in state machine "EmitExpressionAsDouble", // Literal optimization + // --- #766: a nested-block let/const that shadows an enclosing binding gets its own storage; + // these overrides retoken the operator node so the read/write land on the shadow's own + // field/local instead of the outer binding's hoisted field (async analog of #711). + // (EmitVariable/EmitAssign/EmitVarDeclaration above are also rename-aware now.) --- + "EmitConstDeclaration", // #766: route a shadowing const declaration to its own slot + "EmitCompoundAssign", // #766: route a shadowing compound assignment to its own slot + "EmitLogicalAssign", // #766: route a shadowing logical assignment to its own slot + "EmitPrefixIncrement", // #766: route a shadowing prefix ++/-- to its own slot + "EmitPostfixIncrement", // #766: route a shadowing postfix ++/-- to its own slot }, [typeof(GeneratorMoveNextEmitter)] = new() { @@ -160,10 +177,18 @@ public class EmitterSyncTests "EmitSuper", // This field indirection // --- #725: route a captured-and-mutated local through the function display class --- "GetFunctionDCField", // Exposes <>__functionDC so a capturing arrow threads it in - "EmitVariable", // Read a captured-and-mutated local through the function DC - "EmitAssign", // Write a captured-and-mutated local through the function DC + "EmitVariable", // Read a captured-and-mutated local through the function DC (+ #766 rename) + "EmitAssign", // Write a captured-and-mutated local through the function DC (+ #766 rename) "EmitStoreVariable", // Store side of compound/logical/increment through the function DC - "EmitVarDeclaration", // Initialize a captured-and-mutated local into the function DC + "EmitVarDeclaration", // Initialize a captured-and-mutated local into the function DC (+ #766 rename) + // --- #766: a nested-block let/const that shadows an enclosing binding gets its own storage; + // these overrides retoken the operator node so the read/write land on the shadow's own + // field/local instead of the outer binding's hoisted field (async analog of #711) --- + "EmitConstDeclaration", // #766: route a shadowing const declaration to its own slot + "EmitCompoundAssign", // #766: route a shadowing compound assignment to its own slot + "EmitLogicalAssign", // #766: route a shadowing logical assignment to its own slot + "EmitPrefixIncrement", // #766: route a shadowing prefix ++/-- to its own slot + "EmitPostfixIncrement", // #766: route a shadowing postfix ++/-- to its own slot // --- #559: non-local exits must run an enclosing flag-based finally first (async #500) --- "EmitBreak", // Route a break leaving a try through its finally(s) "EmitContinue", // Route a continue leaving a try through its finally(s) diff --git a/SharpTS.Tests/SharedTests/AsyncBlockScopeShadowTests.cs b/SharpTS.Tests/SharedTests/AsyncBlockScopeShadowTests.cs new file mode 100644 index 00000000..c36af088 --- /dev/null +++ b/SharpTS.Tests/SharedTests/AsyncBlockScopeShadowTests.cs @@ -0,0 +1,227 @@ +using SharpTS.Tests.Infrastructure; +using Xunit; + +namespace SharpTS.Tests.SharedTests; + +/// +/// Block-scope shadowing inside async functions and async generators (#766), and the +/// interpreter async-generator nested-block binding-before-await bug (#768). Mirrors the +/// compiled-generator coverage in (#711), extended to the +/// suspension-by-await/yield state machines. Runs against both interpreter and compiler. +/// +public class AsyncBlockScopeShadowTests +{ + #region Async function (#766) + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncFunction_NestedBlockConstShadow_DoesNotLeakToOuter(ExecutionMode mode) + { + // A const in a nested block that shadows an outer body-level const must get its own slot + // instead of clobbering the outer binding's hoisted state-machine field (#766, async #711). + var source = """ + async function af(): Promise { + const r = 100; + { const r = 0; await Promise.resolve(r); } + return r; + } + async function main() { console.log(await af()); } + main(); + """; + + Assert.Equal("100\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncFunction_NestedBlockShadow_LiveAcrossAwait_GetsOwnField(ExecutionMode mode) + { + // The inner shadow is itself read after an await, so it must hoist to its OWN field, distinct + // from the outer binding's field — both must survive the suspension independently (#766). + var source = """ + async function af(): Promise { + const r = 100; + let inner = 0; + { + const r = 5; + await Promise.resolve(0); + inner = r; + } + return inner + "/" + r; + } + async function main() { console.log(await af()); } + main(); + """; + + Assert.Equal("5/100\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncFunction_NestedBlockLetShadow_ReassignedAcrossAwait(ExecutionMode mode) + { + // A let shadow compound-assigned across awaits keeps its own value, separate from the outer + // binding (#766). + var source = """ + async function af(): Promise { + let r = 7; + let captured = 0; + { + let r = 1; + r += 1; + await Promise.resolve(0); + r += 10; + captured = r; + } + return captured + "/" + r; + } + async function main() { console.log(await af()); } + main(); + """; + + Assert.Equal("12/7\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncFunction_NestedBlockShadowsParameter(ExecutionMode mode) + { + // An inner block const may shadow a (hoisted) parameter without clobbering it (#766). + var source = """ + async function af(r: number): Promise { + let captured = 0; + { const r = 99; await Promise.resolve(0); captured = r; } + return captured + "/" + r; + } + async function main() { console.log(await af(5)); } + main(); + """; + + Assert.Equal("99/5\n", TestHarness.Run(source, mode)); + } + + #endregion + + #region Async arrow (#766) + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncArrow_NestedBlockConstShadow_DoesNotLeakToOuter(ExecutionMode mode) + { + // Same shadow leak in a compiled async arrow's state machine (#766). + var source = """ + const af = async (): Promise => { + const r = 100; + { const r = 0; await Promise.resolve(r); } + return r; + }; + async function main() { console.log(await af()); } + main(); + """; + + Assert.Equal("100\n", TestHarness.Run(source, mode)); + } + + #endregion + + #region Async generator (#766 + #768) + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncGenerator_NestedBlockConstShadow_DoesNotLeakToOuter(ExecutionMode mode) + { + // Compiled: the nested-block shadow leaked onto the outer binding's hoisted field → [0, 0] (#766). + // Interpreter: the inner block const yielded before any await read as undefined → [undefined, 100] (#768). + var source = """ + async function* ag(): AsyncGenerator { + const r = 100; + { const r = 0; yield r; } + yield r; + } + async function main() { + const g = ag(); + let a = await g.next(); + let b = await g.next(); + console.log(a.value + "," + b.value); + } + main(); + """; + + Assert.Equal("0,100\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncGenerator_NestedBlockShadow_LiveAcrossYield_GetsOwnField(ExecutionMode mode) + { + // Both the inner shadow and the outer binding survive the suspension on their own slots (#766). + var source = """ + async function* ag(): AsyncGenerator { + const r = 100; + { + const r = 0; + yield r; + yield r + 1; + } + yield r; + } + async function main() { + const g = ag(); + let out = ""; + for (let i = 0; i < 3; i++) out += (await g.next()).value + (i < 2 ? "," : ""); + console.log(out); + } + main(); + """; + + Assert.Equal("0,1,100\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncGenerator_NestedBlockBindingBeforeAwait_YieldsValue(ExecutionMode mode) + { + // #768: distinct names (no shadowing). The interpreter lost a nested-block binding's value when + // it was yielded before the generator first suspended on an await → [undefined, 100]. + var source = """ + async function* ag(): AsyncGenerator { + const a = 100; + { const b = 0; yield b; } + yield a; + } + async function main() { + const g = ag(); + let x = await g.next(); + let y = await g.next(); + console.log(x.value + "," + y.value); + } + main(); + """; + + Assert.Equal("0,100\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncGenerator_NestedBlockLetBeforeAwait_Reassigned(ExecutionMode mode) + { + // #768 variant: a nested-block let read/updated before any await must keep its value. + var source = """ + async function* ag(): AsyncGenerator { + { let b = 1; b += 4; yield b; } + yield 9; + } + async function main() { + const g = ag(); + let x = await g.next(); + let y = await g.next(); + console.log(x.value + "," + y.value); + } + main(); + """; + + Assert.Equal("5,9\n", TestHarness.Run(source, mode)); + } + + #endregion +}