diff --git a/Compilation/AsyncGeneratorMoveNextEmitter.cs b/Compilation/AsyncGeneratorMoveNextEmitter.cs index b3f9a5c0..49d6e2c1 100644 --- a/Compilation/AsyncGeneratorMoveNextEmitter.cs +++ b/Compilation/AsyncGeneratorMoveNextEmitter.cs @@ -88,7 +88,7 @@ public AsyncGeneratorMoveNextEmitter(AsyncGeneratorStateMachineBuilder builder, /// /// Emits the complete MoveNextAsync method body. /// - public void EmitMoveNextAsync(List? body, CompilationContext ctx) + public void EmitMoveNextAsync(List? body, CompilationContext ctx, List? parameters = null) { if (body == null) { @@ -126,6 +126,13 @@ public void EmitMoveNextAsync(List? body, CompilationContext ctx) // Emit state dispatch switch EmitStateSwitch(); + // Apply parameter defaults on initial entry only (state -1 falls through here; resume + // states jump past via the switch, the completed state short-circuits above). (#737) + if (parameters != null) + { + EmitDefaultParameters(parameters); + } + // Emit the function body (will emit yield/await points inline) foreach (var stmt in body) { @@ -169,6 +176,56 @@ public void EmitMoveNextAsync(List? body, CompilationContext ctx) EmitReturnValueTaskBool(false); } + /// + /// Applies parameter defaults at async-generator entry — the async-generator analogue of the sync + /// generator and async function versions (this state machine previously ran no default prologue, so + /// defaults never fired in compiled mode). A defaulted parameter whose argument was omitted or + /// explicitly undefined arrives in its hoisted field as null / the $Undefined sentinel + /// and is replaced with the evaluated default. Evaluated in declaration order so a later default may + /// reference an earlier (already-defaulted) parameter. (#737) + /// + private void EmitDefaultParameters(List parameters) + { + if (!parameters.Any(p => p.DefaultValue != null)) + return; + + foreach (var param in parameters) + { + if (param.DefaultValue == null) continue; + + var field = _builder.GetVariableField(param.Name.Lexeme); + if (field == null) continue; // Parameter not hoisted (unused in body) — default is moot. + + var applyDefault = _il.DefineLabel(); + var checkUndefined = _il.DefineLabel(); + var skipDefault = _il.DefineLabel(); + + // if (field == null) apply; else if (field is $Undefined) apply; else keep. + _il.Emit(OpCodes.Ldarg_0); + _il.Emit(OpCodes.Ldfld, field); + _il.Emit(OpCodes.Dup); + _il.Emit(OpCodes.Brtrue, checkUndefined); + _il.Emit(OpCodes.Pop); // pop the null + _il.Emit(OpCodes.Br, applyDefault); + + _il.MarkLabel(checkUndefined); + _il.Emit(OpCodes.Isinst, _ctx!.Runtime!.UndefinedType); + _il.Emit(OpCodes.Brtrue, applyDefault); + _il.Emit(OpCodes.Br, skipDefault); + + _il.MarkLabel(applyDefault); + EmitExpression(param.DefaultValue); + EnsureBoxed(); + var temp = _il.DeclareLocal(_types.Object); + _il.Emit(OpCodes.Stloc, temp); + _il.Emit(OpCodes.Ldarg_0); + _il.Emit(OpCodes.Ldloc, temp); + _il.Emit(OpCodes.Stfld, field); + + _il.MarkLabel(skipDefault); + } + } + private void EmitStateSwitch() { if (_analysis.SuspensionPointCount == 0) return; diff --git a/Compilation/CallHandlers/AsyncFunctionCallHandler.cs b/Compilation/CallHandlers/AsyncFunctionCallHandler.cs index cb0dd1c6..50e346c5 100644 --- a/Compilation/CallHandlers/AsyncFunctionCallHandler.cs +++ b/Compilation/CallHandlers/AsyncFunctionCallHandler.cs @@ -35,7 +35,7 @@ public bool TryHandle(IEmitterContext emitter, Expr.Call call) } for (int i = call.Arguments.Count; i < paramCount; i++) - emitter.EmitDefaultForType(asyncMethodParams[i].ParameterType); + emitter.EmitOmittedArgument(asyncMethodParams[i].ParameterType); il.Emit(OpCodes.Call, asyncMethod); diff --git a/Compilation/CallHandlers/ClassExprStaticHandler.cs b/Compilation/CallHandlers/ClassExprStaticHandler.cs index 871b6369..c9043660 100644 --- a/Compilation/CallHandlers/ClassExprStaticHandler.cs +++ b/Compilation/CallHandlers/ClassExprStaticHandler.cs @@ -39,7 +39,7 @@ public bool TryHandle(IEmitterContext emitter, Expr.Call call) } for (int i = call.Arguments.Count; i < paramCount; i++) - emitter.EmitDefaultForType(methodParams[i].ParameterType); + emitter.EmitOmittedArgument(methodParams[i].ParameterType); il.Emit(OpCodes.Call, exprStaticMethod); emitter.SetStackUnknown(); diff --git a/Compilation/CallHandlers/ImportedClassStaticHandler.cs b/Compilation/CallHandlers/ImportedClassStaticHandler.cs index e96516ba..ab0e1464 100644 --- a/Compilation/CallHandlers/ImportedClassStaticHandler.cs +++ b/Compilation/CallHandlers/ImportedClassStaticHandler.cs @@ -40,7 +40,7 @@ public bool TryHandle(IEmitterContext emitter, Expr.Call call) } for (int i = call.Arguments.Count; i < paramCount; i++) - emitter.EmitDefaultForType(methodParams[i].ParameterType); + emitter.EmitOmittedArgument(methodParams[i].ParameterType); il.Emit(OpCodes.Call, callableMethod); emitter.SetStackUnknown(); diff --git a/Compilation/CallHandlers/SuperConstructorHandler.cs b/Compilation/CallHandlers/SuperConstructorHandler.cs index 8667a663..47e97b01 100644 --- a/Compilation/CallHandlers/SuperConstructorHandler.cs +++ b/Compilation/CallHandlers/SuperConstructorHandler.cs @@ -234,7 +234,7 @@ private static void EmitSuperCtorCall(IEmitterContext emitter, ConstructorBuilde } for (int i = arguments.Count; i < ctorParams.Length; i++) - emitter.EmitDefaultForType(ctorParams[i].ParameterType); + emitter.EmitOmittedArgument(ctorParams[i].ParameterType); System.Reflection.ConstructorInfo ctorToCall = parentCtor; Type? baseType = ctx.CurrentClassBuilder?.BaseType; diff --git a/Compilation/CallHandlers/ThisStaticContextHandler.cs b/Compilation/CallHandlers/ThisStaticContextHandler.cs index 71aee189..d32c0fbb 100644 --- a/Compilation/CallHandlers/ThisStaticContextHandler.cs +++ b/Compilation/CallHandlers/ThisStaticContextHandler.cs @@ -45,7 +45,7 @@ public bool TryHandle(IEmitterContext emitter, Expr.Call call) } for (int i = call.Arguments.Count; i < paramCount; i++) - emitter.EmitDefaultForType(methodParams[i].ParameterType); + emitter.EmitOmittedArgument(methodParams[i].ParameterType); il.Emit(OpCodes.Call, thisStaticMethod); emitter.SetStackUnknown(); diff --git a/Compilation/EmittedRuntime.cs b/Compilation/EmittedRuntime.cs index 830e4bf7..1ad8ef0b 100644 --- a/Compilation/EmittedRuntime.cs +++ b/Compilation/EmittedRuntime.cs @@ -274,6 +274,13 @@ public class EmittedRuntime public TypeBuilder PadUndefinedAttrType { get; set; } = null!; public ConstructorBuilder PadUndefinedAttrCtor { get; set; } = null!; + // Marker attribute applied to a user function-expression / `this`-bearing arrow method whose + // first emitted parameter is the synthetic `__this` receiver slot. $TSFunction reads it back via + // IsDefined so it can detect that slot without the parameter name (a --ref-asm rewrite strips + // parameter names, breaking the name-based check and shifting value-call arguments). (#738) + public TypeBuilder ExpectsThisAttrType { get; set; } = null!; + public ConstructorBuilder ExpectsThisAttrCtor { get; set; } = null!; + // String methods public MethodBuilder StringCharAt { get; set; } = null!; public MethodBuilder StringSubstring { get; set; } = null!; diff --git a/Compilation/Emitters/IEmitterContext.cs b/Compilation/Emitters/IEmitterContext.cs index 7f010e60..3a0ad0da 100644 --- a/Compilation/Emitters/IEmitterContext.cs +++ b/Compilation/Emitters/IEmitterContext.cs @@ -78,6 +78,14 @@ public interface IEmitterContext /// void EmitDefaultForType(Type type); + /// + /// Emits the value for a trailing parameter slot the call site omits: the $Undefined + /// sentinel for an object slot (JS: an omitted argument is undefined), else the + /// type's CLR default. Use this instead of when padding omitted + /// call arguments. (#739/#705) + /// + void EmitOmittedArgument(Type slotType); + /// /// Emits IL that constructs a delegate of the given type pointing at the /// arrow's compiled body. For non-capturing arrows: new Func(null, ldftn staticMethod). diff --git a/Compilation/ExpressionEmitterBase.CallHelpers.cs b/Compilation/ExpressionEmitterBase.CallHelpers.cs index aafb50b0..421b13dd 100644 --- a/Compilation/ExpressionEmitterBase.CallHelpers.cs +++ b/Compilation/ExpressionEmitterBase.CallHelpers.cs @@ -862,7 +862,7 @@ promisesGet.Object is Expr.Variable promisesModuleVar && } for (int i = c.Arguments.Count; i < paramCount; i++) - EmitDefaultForType(staticMethodParams[i].ParameterType); + EmitOmittedArgument(staticMethodParams[i].ParameterType); IL.Emit(OpCodes.Call, callableMethod); SetStackUnknown(); @@ -1525,7 +1525,7 @@ protected bool TryEmitDirectMethodCall(Expr receiver, string methodName, List argu for (int i = arguments.Count; i < methodParams.Length; i++) { - EmitDefaultForType(methodParams[i].ParameterType); + EmitOmittedArgument(methodParams[i].ParameterType); } IL.Emit(OpCodes.Call, superTarget); @@ -1802,7 +1802,7 @@ protected bool TryEmitDerivedPromiseStatic(string resolvedClassName, Type classB } for (int i = 1; i < ctorParams.Length; i++) - EmitDefaultForType(ctorParams[i].ParameterType); + EmitOmittedArgument(ctorParams[i].ParameterType); IL.Emit(OpCodes.Newobj, ctorToCall); SetStackUnknown(); @@ -1983,6 +1983,27 @@ protected void EmitDefaultForType(Type type) else { IL.Emit(OpCodes.Ldnull); } } + /// + /// Emits the value for a trailing parameter slot that the call site omits. JS semantics: an + /// omitted argument is undefined, so an object slot — which every optional or + /// value-type-defaulted parameter uses after widening (#705/#739) — is padded with the + /// $Undefined sentinel. That is observable through typeof/=== undefined for a + /// plain optional (#739) and fires the default-parameter prologue for a defaulted one + /// (#705/#723/#737). A non-object slot (a required value-type slot, or a not-widened + /// reference-typed default such as string) takes its CLR default — for a defaulted + /// reference slot the prologue still fires on the resulting null. Mirrors the existing + /// $TSFunction.Invoke value-call padding and . + /// Public (unlike the sibling ) so the ILCompiler can pad an + /// implicit base-constructor call directly; also satisfies . + /// + public void EmitOmittedArgument(Type slotType) + { + if (slotType == Types.Object) + EmitUndefinedConstant(); + else + EmitDefaultForType(slotType); + } + /// /// Emits conversion from the current stack value to the target parameter type. /// Handles boxing for object, unboxing for value types, union types, and pass-through for matching types. @@ -2119,7 +2140,7 @@ protected void EmitSuperConstructorCall(ConstructorBuilder parentCtor, List namespaceParts, string clas } } - // Pad missing optional arguments + // Pad omitted trailing arguments (object slot → `undefined` sentinel). (#739/#705) for (int i = n.Arguments.Count; i < expectedParamCount; i++) { - EmitDefaultForType(ctorParams[i].ParameterType); + EmitOmittedArgument(ctorParams[i].ParameterType); } IL.Emit(OpCodes.Newobj, targetCtor); @@ -1512,7 +1512,7 @@ private void EmitClassExprConstruction(ConstructorBuilder classExprCtor, Expr.Ne } for (int i = n.Arguments.Count; i < expectedParamCount; i++) - EmitDefaultForType(ctorParams[i].ParameterType); + EmitOmittedArgument(ctorParams[i].ParameterType); IL.Emit(OpCodes.Newobj, classExprCtor); SetStackUnknown(); diff --git a/Compilation/GeneratorMoveNextEmitter.cs b/Compilation/GeneratorMoveNextEmitter.cs index d0cb800e..e788145f 100644 --- a/Compilation/GeneratorMoveNextEmitter.cs +++ b/Compilation/GeneratorMoveNextEmitter.cs @@ -68,7 +68,10 @@ public GeneratorMoveNextEmitter(GeneratorStateMachineBuilder builder, GeneratorS /// /// Emits the complete MoveNext method body. /// - public void EmitMoveNext(List? body, CompilationContext ctx) + /// The generator's declared parameters. When supplied, a default-parameter + /// prologue runs on initial entry so an omitted or explicit-undefined argument fires its + /// default (#737). Null skips it (callers with no params). + public void EmitMoveNext(List? body, CompilationContext ctx, List? parameters = null) { if (body == null) return; @@ -101,6 +104,15 @@ public void EmitMoveNext(List? body, CompilationContext ctx) // Emit state dispatch switch EmitStateSwitch(); + // Apply parameter defaults on initial entry. The state switch jumps every resume state + // (>= 0) to its yield label and the completed state (-2) short-circuits above, so only the + // initial entry (state -1) falls through to here — the defaults run exactly once, before the + // body, with no extra guard field needed. (#737) + if (parameters != null) + { + EmitDefaultParameters(parameters); + } + // Emit the function body (will emit yield points inline) foreach (var stmt in body) { @@ -152,6 +164,57 @@ public void EmitMoveNext(List? body, CompilationContext ctx) _il.Emit(OpCodes.Ret); } + /// + /// Applies parameter defaults at generator entry. A defaulted parameter whose argument was + /// omitted or explicitly undefined arrives in its hoisted state-machine field as null / + /// the $Undefined sentinel; this replaces it with the evaluated default expression — the + /// generator analogue of and the async emitter's + /// own version, both of which a generator's state machine previously lacked (defaults never + /// fired in compiled mode). Defaults are evaluated in declaration order, so a later default may + /// reference an earlier (already-defaulted) parameter via its field. (#737) + /// + private void EmitDefaultParameters(List parameters) + { + if (!parameters.Any(p => p.DefaultValue != null)) + return; + + foreach (var param in parameters) + { + if (param.DefaultValue == null) continue; + + var field = _builder.GetVariableField(param.Name.Lexeme); + if (field == null) continue; // Parameter not hoisted (unused in body) — default is moot. + + var applyDefault = _il.DefineLabel(); + var checkUndefined = _il.DefineLabel(); + var skipDefault = _il.DefineLabel(); + + // if (field == null) apply; else if (field is $Undefined) apply; else keep. + _il.Emit(OpCodes.Ldarg_0); + _il.Emit(OpCodes.Ldfld, field); + _il.Emit(OpCodes.Dup); + _il.Emit(OpCodes.Brtrue, checkUndefined); + _il.Emit(OpCodes.Pop); // pop the null + _il.Emit(OpCodes.Br, applyDefault); + + _il.MarkLabel(checkUndefined); + _il.Emit(OpCodes.Isinst, _ctx!.Runtime!.UndefinedType); + _il.Emit(OpCodes.Brtrue, applyDefault); + _il.Emit(OpCodes.Br, skipDefault); + + _il.MarkLabel(applyDefault); + EmitExpression(param.DefaultValue); + EnsureBoxed(); + var temp = _il.DeclareLocal(_types.Object); + _il.Emit(OpCodes.Stloc, temp); + _il.Emit(OpCodes.Ldarg_0); + _il.Emit(OpCodes.Ldloc, temp); + _il.Emit(OpCodes.Stfld, field); + + _il.MarkLabel(skipDefault); + } + } + private void EmitStateSwitch() { if (_analysis.YieldPointCount == 0) return; diff --git a/Compilation/ILCompiler.ArrowFunctions.cs b/Compilation/ILCompiler.ArrowFunctions.cs index bb5b6742..da117b30 100644 --- a/Compilation/ILCompiler.ArrowFunctions.cs +++ b/Compilation/ILCompiler.ArrowFunctions.cs @@ -241,6 +241,9 @@ private void FinalizeArrowFunctionCollection() methodBuilder.DefineParameter(1, ParameterAttributes.None, "__this"); for (int i = 0; i < arrow.Parameters.Count; i++) methodBuilder.DefineParameter(i + 2, ParameterAttributes.None, arrow.Parameters[i].Name.Lexeme); + // Back the name-based __this detection with an attribute that survives a --ref-asm + // parameter-name strip, so a value-call doesn't shift its arguments. (#738) + MarkExpectsThis(methodBuilder); } else { @@ -395,6 +398,8 @@ private void FinalizeArrowFunctionCollection() invokeMethod.DefineParameter(1, ParameterAttributes.None, "__this"); for (int i = 0; i < arrow.Parameters.Count; i++) invokeMethod.DefineParameter(i + 2, ParameterAttributes.None, arrow.Parameters[i].Name.Lexeme); + // Survive a --ref-asm parameter-name strip (see the sibling site above). (#738) + MarkExpectsThis(invokeMethod); } else { diff --git a/Compilation/ILCompiler.AsyncGenerators.cs b/Compilation/ILCompiler.AsyncGenerators.cs index 092f8ae6..20246683 100644 --- a/Compilation/ILCompiler.AsyncGenerators.cs +++ b/Compilation/ILCompiler.AsyncGenerators.cs @@ -171,7 +171,7 @@ private void EmitAsyncGeneratorMoveNextAsyncBody(AsyncGeneratorStateMachineBuild // Use the new emitter for full async generator body emission var emitter = new AsyncGeneratorMoveNextEmitter(smBuilder, analysis, _types); - emitter.EmitMoveNextAsync(funcStmt.Body, ctx); + emitter.EmitMoveNextAsync(funcStmt.Body, ctx, funcStmt.Parameters); } /// @@ -246,7 +246,7 @@ private void EmitAsyncGeneratorMethodBody(MethodBuilder methodBuilder, Stmt.Func // Emit MoveNextAsync body var moveNextEmitter = new AsyncGeneratorMoveNextEmitter(smBuilder, analysis, _types); - moveNextEmitter.EmitMoveNextAsync(method.Body, ctx); + moveNextEmitter.EmitMoveNextAsync(method.Body, ctx, method.Parameters); // Finalize the state machine type smBuilder.CreateType(); diff --git a/Compilation/ILCompiler.Classes.ClassExpressions.cs b/Compilation/ILCompiler.Classes.ClassExpressions.cs index 23f6988e..ab7dc4bb 100644 --- a/Compilation/ILCompiler.Classes.ClassExpressions.cs +++ b/Compilation/ILCompiler.Classes.ClassExpressions.cs @@ -653,7 +653,7 @@ private void EmitClassExpressionConstructor(Expr.ClassExpr classExpr, TypeBuilde if (baseCtor != null) { foreach (var p in baseCtor.GetParameters()) - emitter.EmitDefaultForType(p.ParameterType); + emitter.EmitOmittedArgument(p.ParameterType); il.Emit(OpCodes.Call, baseCtor); } else diff --git a/Compilation/ILCompiler.Functions.cs b/Compilation/ILCompiler.Functions.cs index 7b3b4413..b4bcdb4d 100644 --- a/Compilation/ILCompiler.Functions.cs +++ b/Compilation/ILCompiler.Functions.cs @@ -1461,6 +1461,20 @@ internal void MarkPadsUndefined(MethodBuilder method) new System.Reflection.Emit.CustomAttributeBuilder(_runtime.PadUndefinedAttrCtor, [])); } + /// + /// Marks a method whose first emitted parameter is the synthetic __this receiver slot + /// (a user function expression or this-bearing arrow) with the $ExpectsThis + /// attribute, so $TSFunction can detect that slot via IsDefined instead of the + /// parameter name — which a --ref-asm rewrite strips, otherwise shifting value-call + /// arguments by one. No-op when the runtime attribute is unavailable. (#738) + /// + internal void MarkExpectsThis(MethodBuilder method) + { + if (_runtime?.ExpectsThisAttrCtor != null) + method.SetCustomAttribute( + new System.Reflection.Emit.CustomAttributeBuilder(_runtime.ExpectsThisAttrCtor, [])); + } + /// /// Gets the index of the first parameter with a default value. /// Returns -1 if no default parameters exist. diff --git a/Compilation/ILCompiler.Generators.cs b/Compilation/ILCompiler.Generators.cs index a858f6ce..94eaa2e5 100644 --- a/Compilation/ILCompiler.Generators.cs +++ b/Compilation/ILCompiler.Generators.cs @@ -323,7 +323,7 @@ private void EmitGeneratorMoveNextBody(GeneratorStateMachineBuilder smBuilder, S // Use the new emitter for full generator body emission var emitter = new GeneratorMoveNextEmitter(smBuilder, analysis, _types); - emitter.EmitMoveNext(funcStmt.Body, ctx); + emitter.EmitMoveNext(funcStmt.Body, ctx, funcStmt.Parameters); ILLabelValidator.Validate(smBuilder.MoveNextMethod.GetILGenerator(), $"generator MoveNext {funcStmt.Name.Lexeme}"); @@ -402,7 +402,7 @@ private void EmitGeneratorMethodBody(MethodBuilder methodBuilder, Stmt.Function // Emit MoveNext body var moveNextEmitter = new GeneratorMoveNextEmitter(smBuilder, analysis, _types); - moveNextEmitter.EmitMoveNext(method.Body, ctx); + moveNextEmitter.EmitMoveNext(method.Body, ctx, method.Parameters); ILLabelValidator.Validate(il, $"generator method MoveNext {methodBuilder.DeclaringType?.Name}::{method.Name.Lexeme}"); diff --git a/Compilation/ILEmitter.Calls.Constructors.cs b/Compilation/ILEmitter.Calls.Constructors.cs index 63e92c84..8b47ed95 100644 --- a/Compilation/ILEmitter.Calls.Constructors.cs +++ b/Compilation/ILEmitter.Calls.Constructors.cs @@ -149,7 +149,7 @@ private void EmitTypedClassConstruction(TypeBuilder typeBuilder, ConstructorBuil } for (int i = n.Arguments.Count; i < expectedParamCount; i++) - EmitDefaultForType(ctorParams[i].ParameterType); + EmitOmittedArgument(ctorParams[i].ParameterType); IL.Emit(OpCodes.Newobj, targetCtor); SetStackUnknown(); @@ -230,12 +230,13 @@ private void EmitInferredGenericClassConstruction( } else if (pt.IsGenericParameter) { - // Under-applied generic-typed parameter: default for the closed type. - EmitDefaultForType(inferred[pt.GenericParameterPosition]!); + // Under-applied generic-typed parameter: pad with undefined for an object closure, + // else the closed type's CLR default. (#739) + EmitOmittedArgument(inferred[pt.GenericParameterPosition]!); } else { - EmitDefaultForType(pt); + EmitOmittedArgument(pt); } } @@ -294,7 +295,7 @@ private void EmitClassExprCtorCall(ConstructorInfo ctor, ParameterInfo[] ctorPar } for (int i = n.Arguments.Count; i < expectedParamCount; i++) - EmitDefaultForType(ctorParams[i].ParameterType); + EmitOmittedArgument(ctorParams[i].ParameterType); IL.Emit(OpCodes.Newobj, ctor); SetStackUnknown(); diff --git a/Compilation/ILEmitter.Calls.MethodDispatch.cs b/Compilation/ILEmitter.Calls.MethodDispatch.cs index 3d1b24c1..b349bcd5 100644 --- a/Compilation/ILEmitter.Calls.MethodDispatch.cs +++ b/Compilation/ILEmitter.Calls.MethodDispatch.cs @@ -312,10 +312,12 @@ private bool TryEmitDirectMethodCall(Expr receiver, TypeSystem.TypeInfo? receive EmitConversionForParameter(arg, targetParams[i].ParameterType); } - // Pad missing required-position arguments with defaults. + // Pad omitted trailing arguments. An optional/defaulted param uses an object slot, so it is + // padded with the `undefined` sentinel — observable via typeof for a plain optional and + // firing the entry prologue for a defaulted one. (#739/#705) for (int i = regularArgsToEmit; i < regularParamCount; i++) { - EmitDefaultForType(targetParams[i].ParameterType); + EmitOmittedArgument(targetParams[i].ParameterType); } if (hasRestParam) @@ -416,10 +418,10 @@ protected override bool TryEmitSuperMethodCall(string methodName, List arg } } - // Pad missing optional arguments + // Pad omitted trailing arguments (object slot → `undefined` sentinel). (#739/#705) for (int i = arguments.Count; i < methodParams.Length; i++) { - EmitDefaultForType(methodParams[i].ParameterType); + EmitOmittedArgument(methodParams[i].ParameterType); } // Use Call (NOT Callvirt) to bypass virtual dispatch diff --git a/Compilation/ILEmitter.Calls.cs b/Compilation/ILEmitter.Calls.cs index 0b1b74de..023777a8 100644 --- a/Compilation/ILEmitter.Calls.cs +++ b/Compilation/ILEmitter.Calls.cs @@ -196,7 +196,7 @@ promisesGet.Object is Expr.Variable promisesModuleVar && EmitBoxIfNeeded(c.Arguments[i]); } for (int i = c.Arguments.Count; i < staticMethodParams.Length; i++) - EmitDefaultForType(staticMethodParams[i].ParameterType); + EmitOmittedArgument(staticMethodParams[i].ParameterType); IL.Emit(OpCodes.Call, callableMethod); SetStackUnknown(); return; diff --git a/Compilation/ParameterTypeResolver.cs b/Compilation/ParameterTypeResolver.cs index 87e98f0b..e5d0e3ae 100644 --- a/Compilation/ParameterTypeResolver.cs +++ b/Compilation/ParameterTypeResolver.cs @@ -311,15 +311,21 @@ public static Type ResolveReturnType( /// Resolves parameter types for a class method. /// /// - /// Deliberately does NOT widen value-type-defaulted params to object (unlike free - /// functions and constructors, which do). Instance methods are virtual: changing a - /// defaulted param's slot from double/bool to object would change the CLR - /// signature and break override matching against a base method that declares the same param - /// without a default (the derived override silently lands in a new vtable slot, so a - /// base-typed call dispatches to the base method). Reference-type defaults still fire via the - /// entry prologue without any slot change (a reference slot already holds null). Firing - /// value-type method defaults override-safely needs hierarchy-consistent widening or bridge - /// methods — tracked as #737. + /// A parameter with a value-type default (x: number = N) needs an object + /// slot so the entry prologue can observe the $Undefined sentinel and fire the default + /// (a double/bool slot cannot — it coerces the sentinel to NaN/false). + /// + /// Static methods are non-virtual, so widening such a param is always safe — done + /// directly (#705/#723). + /// Instance methods are virtual: widening one override's slot would change its CLR + /// signature and silently break override matching (the derived method lands in a new vtable + /// slot). So the decision is made hierarchy-consistently — a position is widened across + /// the WHOLE override group when any member makes it an optional value-type parameter, keeping + /// every override's signature identical (see ). + /// (#737) + /// + /// Reference-type defaults need no slot change (a reference slot already holds null, which the + /// prologue treats as undefined), so they are left alone in both cases. /// public static Type[] ResolveMethodParameters( string className, @@ -336,6 +342,7 @@ public static Type[] ResolveMethodParameters( return parameters.Select(p => ResolveParameterType(p, typeMapper)).ToArray(); TSTypeInfo.Function? funcType = null; + bool isStatic = false; // Check instance methods if (classType.Methods.TryGetValue(methodName, out var methodType)) @@ -346,48 +353,202 @@ public static Type[] ResolveMethodParameters( else if (classType.StaticMethods.TryGetValue(methodName, out var staticMethodType)) { funcType = ExtractFunctionType(staticMethodType); + isStatic = true; } + Type[] resolved; if (funcType == null || funcType.ParamTypes.Count != parameters.Count) - return parameters.Select(p => WidenIfUndefinedReachableParam(ResolveParameterType(p, typeMapper), p, typeMap)).ToArray(); + { + resolved = parameters.Select(p => WidenIfUndefinedReachableParam(ResolveParameterType(p, typeMapper), p, typeMap)).ToArray(); + } + else + { + // Map each parameter type, handling optional parameters and BigInteger + resolved = funcType.ParamTypes + .Select((pt, i) => + { + Type mappedType; + try + { + mappedType = typeMapper.MapTypeInfoStrict(pt); + } + catch + { + // Union types may throw during early method definition phase + // when TypeBuilder isn't finalized yet. Fall back to object. + return typeof(object); + } - // Map each parameter type, handling optional parameters and BigInteger - return funcType.ParamTypes - .Select((pt, i) => + // BigInteger parameters need to stay as object because BigInt operations + // in the emitter expect boxed values + if (mappedType == typeof(System.Numerics.BigInteger)) + { + return typeof(object); + } + + // If parameter is optional (no explicit default), use object so undefined + // can be passed as the missing-argument sentinel (JS spec) + if (i < parameters.Count && + parameters[i].DefaultValue == null && + parameters[i].IsOptional) + { + return typeof(object); + } + + return i < parameters.Count + ? CoerceParamSlotType(WidenIfUndefinedReachableParam(mappedType, parameters[i], typeMap), pt, typeMapper) + : CoerceParamSlotType(mappedType, pt, typeMapper); + }) + .ToArray(); + } + + WidenValueTypeDefaultedMethodParams(resolved, parameters, className, methodName, isStatic, typeMapper, typeMap); + return resolved; + } + + /// + /// Widens a method parameter slot to object wherever a value-type default needs to be able + /// to hold the $Undefined sentinel — non-virtually for static methods, and + /// hierarchy-consistently for (virtual) instance methods. No-op when no value-type defaults are + /// involved, so the common method keeps its fast unboxed slots. (#705/#723/#737) + /// + private static void WidenValueTypeDefaultedMethodParams( + Type[] resolved, + List parameters, + string className, + string methodName, + bool isStatic, + TypeMapper typeMapper, + TypeMap typeMap) + { + if (isStatic) + { + // Non-virtual: widening a value-type-defaulted param can never break override matching. + for (int i = 0; i < parameters.Count && i < resolved.Length; i++) + if (parameters[i].DefaultValue != null && !parameters[i].IsRest && resolved[i].IsValueType) + resolved[i] = typeof(object); + return; + } + + // Virtual: widen each value-type slot the override group needs as object, so every override + // keeps an identical CLR signature (preserving vtable dispatch). + var mask = ComputeInstanceMethodWidenMask(className, methodName, resolved.Length, typeMapper, typeMap); + for (int i = 0; i < resolved.Length; i++) + if (mask[i] && resolved[i].IsValueType) + resolved[i] = typeof(object); + } + + /// + /// Computes, for an instance (virtual) method, which parameter positions must use an + /// object slot so a value-type default can fire via the entry prologue. The decision is + /// hierarchy-consistent: a position is flagged when ANY member of the method's override + /// group (its root declaration plus every class that overrides it) makes that position an + /// optional value-type parameter. Flagging the whole group keeps every override's CLR signature + /// identical, so a derived override that adds a default still lands in the base's vtable slot + /// and a base-typed call dispatches to it correctly. (#737) + /// + private static bool[] ComputeInstanceMethodWidenMask( + string className, string methodName, int paramCount, TypeMapper typeMapper, TypeMap typeMap) + { + var mask = new bool[paramCount]; + if (paramCount == 0) + return mask; + + var root = FindMethodRootDeclarer(className, methodName, typeMap); + if (root == null) + return mask; + + // Union the optional-value-type positions over every class whose method shares this root + // declaration (i.e. the override group). A class only declares the method if it is a key in + // its OWN Methods map (lookup walks the chain, but Methods holds own-declared entries only). + foreach (var (otherName, otherClass) in typeMap.ClassTypes) + { + if (!otherClass.Methods.TryGetValue(methodName, out var otherMethodType)) + continue; + if (FindMethodRootDeclarer(otherName, methodName, typeMap) != root) + continue; + if (ExtractFunctionType(otherMethodType) is not { } f) + continue; + + int minArity = f.MinArity; + int count = Math.Min(paramCount, f.ParamTypes.Count); + for (int i = 0; i < count; i++) { - Type mappedType; - try - { - mappedType = typeMapper.MapTypeInfoStrict(pt); - } - catch - { - // Union types may throw during early method definition phase - // when TypeBuilder isn't finalized yet. Fall back to object. - return typeof(object); - } + // Positions below the member's arity are required there — a required param is always + // supplied, so it never needs an undefined-capable slot on this member's behalf. + if (i < minArity) + continue; + if (IsValueTypeParamSlot(typeMapper, f.ParamTypes[i])) + mask[i] = true; + } + } - // BigInteger parameters need to stay as object because BigInt operations - // in the emitter expect boxed values - if (mappedType == typeof(System.Numerics.BigInteger)) - { - return typeof(object); - } + return mask; + } - // If parameter is optional (no explicit default), use object so undefined - // can be passed as the missing-argument sentinel (JS spec) - if (i < parameters.Count && - parameters[i].DefaultValue == null && - parameters[i].IsOptional) - { - return typeof(object); - } + /// + /// Returns the name of the topmost class in 's ancestry (inclusive) + /// that declares — the class that owns the method's vtable slot. + /// Two classes share an override group iff they have the same root declarer. Returns null if the + /// method is not declared anywhere in the chain. + /// + private static string? FindMethodRootDeclarer(string className, string methodName, TypeMap typeMap) + { + var cls = typeMap.GetClassType(className); + if (cls == null) + return null; + + string? root = cls.Methods.ContainsKey(methodName) ? className : null; + var visited = new HashSet(StringComparer.Ordinal) { className }; + string? superName = GetSuperclassName(cls); + while (superName != null && visited.Add(superName)) + { + var super = typeMap.GetClassType(superName); + if (super == null) + break; + if (super.Methods.ContainsKey(methodName)) + root = superName; + superName = GetSuperclassName(super); + } + return root; + } - return i < parameters.Count - ? CoerceParamSlotType(WidenIfUndefinedReachableParam(mappedType, parameters[i], typeMap), pt, typeMapper) - : CoerceParamSlotType(mappedType, pt, typeMapper); - }) - .ToArray(); + /// + /// Extracts the (simple) name of a class's direct superclass, unwrapping an instantiated generic + /// base (extends Box<number>) to its generic definition's name. Returns null when the + /// class has no superclass. + /// + private static string? GetSuperclassName(TSTypeInfo.Class cls) => cls.Superclass switch + { + TSTypeInfo.Class c => c.Name, + TSTypeInfo.MutableClass mc => mc.Name, + TSTypeInfo.GenericClass gc => gc.Name, + TSTypeInfo.InstantiatedGeneric ig => ig.GenericDefinition switch + { + TSTypeInfo.Class c => c.Name, + TSTypeInfo.MutableClass mc => mc.Name, + TSTypeInfo.GenericClass gc => gc.Name, + _ => null + }, + _ => null + }; + + /// + /// True when a parameter's TS type maps to an unboxed CLR value-type slot (e.g. number → + /// double, booleanbool) — the slots that cannot hold the $Undefined + /// sentinel and so must be widened to object when defaulted. Maps defensively (a mapping + /// can throw during early definition) and treats failures as non-value-type (do not widen). + /// + private static bool IsValueTypeParamSlot(TypeMapper typeMapper, TSTypeInfo paramType) + { + try + { + return typeMapper.MapTypeInfoStrict(paramType).IsValueType; + } + catch + { + return false; + } } /// diff --git a/Compilation/RuntimeEmitter.TSFunction.cs b/Compilation/RuntimeEmitter.TSFunction.cs index 26f4e656..994b2288 100644 --- a/Compilation/RuntimeEmitter.TSFunction.cs +++ b/Compilation/RuntimeEmitter.TSFunction.cs @@ -83,6 +83,35 @@ private void EmitPadUndefinedAttribute(ModuleBuilder moduleBuilder, EmittedRunti typeBuilder.CreateType(); } + /// + /// Emits a minimal marker attribute $ExpectsThis (empty + /// subclass) applied to a user function-expression / this-bearing arrow method, whose + /// first emitted parameter is the synthetic __this receiver slot. $TSFunction + /// normally detects that slot by parameter name (params[0].Name == "__this"), but a + /// reference-assembly rewrite (--compile --ref-asm) strips parameter names, so the name + /// check fails and the receiver slot is miscounted as a real argument — shifting a value-call's + /// arguments by one. Custom attributes survive the rewrite, so reading this marker back via + /// restores correct detection. + /// Lives in the output assembly so the compiled DLL stays standalone. (#738) + /// + private void EmitExpectsThisAttribute(ModuleBuilder moduleBuilder, EmittedRuntime runtime) + { + var typeBuilder = moduleBuilder.DefineType( + "$ExpectsThis", + TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.BeforeFieldInit, + typeof(System.Attribute)); + var ctor = typeBuilder.DefineConstructor( + MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var ctorIl = ctor.GetILGenerator(); + ctorIl.Emit(OpCodes.Ldarg_0); + ctorIl.Emit(OpCodes.Call, typeof(System.Attribute).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null)!); + ctorIl.Emit(OpCodes.Ret); + runtime.ExpectsThisAttrCtor = ctor; + runtime.ExpectsThisAttrType = typeBuilder; + typeBuilder.CreateType(); + } + private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime runtime) { // Define class: public sealed class $TSFunction @@ -268,7 +297,7 @@ private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime run ctorIL.Emit(OpCodes.Ldnull); ctorIL.Emit(OpCodes.Stfld, cachedNameField); // this._expectsThis = (method.GetParameters().Length > 0 && params[0].Name == "__this") - EmitComputeExpectsThis(ctorIL, expectsThisField, methodArgIndex: 2); + EmitComputeExpectsThis(ctorIL, expectsThisField, runtime, methodArgIndex: 2); // this._capturesArguments = method.IsDefined($CapturesArguments) EmitComputeCapturesArguments(ctorIL, capturesArgumentsField, runtime, methodArgIndex: 2); // this._padUndefinedMask = $PadUndefined ? (object-param bits) : 0 @@ -310,7 +339,7 @@ private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime run ctorCacheIL.Emit(OpCodes.Ldarg, 4); // 4th argument (0-indexed: 0=this, 1=target, 2=method, 3=name, 4=length) ctorCacheIL.Emit(OpCodes.Stfld, cachedLengthField); // this._expectsThis = (method.GetParameters().Length > 0 && params[0].Name == "__this") - EmitComputeExpectsThis(ctorCacheIL, expectsThisField, methodArgIndex: 2); + EmitComputeExpectsThis(ctorCacheIL, expectsThisField, runtime, methodArgIndex: 2); EmitComputeCapturesArguments(ctorCacheIL, capturesArgumentsField, runtime, methodArgIndex: 2); EmitComputePadUndefinedMask(ctorCacheIL, padUndefinedMaskField, runtime, methodArgIndex: 2); EmitComputeAdjustArgsCache(ctorCacheIL, paramCountField, hasListRestField, hasArrayRestField, methodArgIndex: 2); @@ -1264,46 +1293,55 @@ private void EmitTypeEqAndBranchTrue(ILGenerator il, LocalBuilder paramTypeLocal } /// - /// Emits IL inside a constructor to compute and store the cached - /// _expectsThis bool field. Logic: - /// this._expectsThis = (method.GetParameters().Length > 0 && params[0].Name == "__this"). - /// Caller must provide the field and the constructor's argument index of - /// the MethodInfo param (typically 2). + /// Emits IL inside a constructor to compute and store the cached _expectsThis bool field: + /// this._expectsThis = (params.Length > 0 && params[0].Name == "__this") + /// || method.IsDefined(typeof($ExpectsThis), false). The name check is the primary path; the + /// $ExpectsThis attribute backstops it because a --ref-asm rewrite strips parameter + /// names (so the name check fails there, otherwise shifting a value-call's arguments). Caller + /// provides the field and the constructor argument index of the MethodInfo param. (#738) /// - private void EmitComputeExpectsThis(ILGenerator il, FieldBuilder expectsThisField, int methodArgIndex) + private void EmitComputeExpectsThis(ILGenerator il, FieldBuilder expectsThisField, EmittedRuntime runtime, int methodArgIndex) { var paramsLocal = il.DeclareLocal(_types.MakeArrayType(_types.ParameterInfo)); - var falseLabel = il.DefineLabel(); - var doneLabel = il.DefineLabel(); + var resultLocal = il.DeclareLocal(_types.Boolean); + var checkAttr = il.DefineLabel(); + var store = il.DefineLabel(); // params = method.GetParameters() il.Emit(OpCodes.Ldarg, methodArgIndex); il.Emit(OpCodes.Callvirt, _types.MethodInfo.GetMethod("GetParameters")!); il.Emit(OpCodes.Stloc, paramsLocal); - // [this] for the eventual stfld - il.Emit(OpCodes.Ldarg_0); - - // if (params.Length == 0) goto falseLabel + // nameMatch = params.Length > 0 && params[0].Name == "__this" il.Emit(OpCodes.Ldloc, paramsLocal); il.Emit(OpCodes.Ldlen); il.Emit(OpCodes.Conv_I4); - il.Emit(OpCodes.Brfalse, falseLabel); + il.Emit(OpCodes.Brfalse, checkAttr); // 0 params → fall back to the attribute - // params[0].Name == "__this" — leaves bool on stack il.Emit(OpCodes.Ldloc, paramsLocal); il.Emit(OpCodes.Ldc_I4_0); il.Emit(OpCodes.Ldelem_Ref); il.Emit(OpCodes.Callvirt, _types.ParameterInfo.GetProperty("Name")!.GetGetMethod()!); il.Emit(OpCodes.Ldstr, "__this"); il.Emit(OpCodes.Call, _types.String.GetMethod("op_Equality", [_types.String, _types.String])!); - il.Emit(OpCodes.Br, doneLabel); + il.Emit(OpCodes.Stloc, resultLocal); + + il.Emit(OpCodes.Ldloc, resultLocal); + il.Emit(OpCodes.Brtrue, store); // name matched → done - il.MarkLabel(falseLabel); + // Backstop: resultLocal = method.IsDefined(typeof($ExpectsThis), false) — survives the + // parameter-name strip the name check above does not. + il.MarkLabel(checkAttr); + il.Emit(OpCodes.Ldarg, methodArgIndex); + il.Emit(OpCodes.Ldtoken, runtime.ExpectsThisAttrType); + il.Emit(OpCodes.Call, _types.GetMethod(_types.Type, "GetTypeFromHandle", _types.RuntimeTypeHandle)); il.Emit(OpCodes.Ldc_I4_0); + il.Emit(OpCodes.Callvirt, _types.GetMethod(_types.MethodInfo, "IsDefined", _types.Type, _types.Boolean)); + il.Emit(OpCodes.Stloc, resultLocal); - il.MarkLabel(doneLabel); - // Stack: [this, bool] → stfld + il.MarkLabel(store); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldloc, resultLocal); il.Emit(OpCodes.Stfld, expectsThisField); } diff --git a/Compilation/RuntimeEmitter.cs b/Compilation/RuntimeEmitter.cs index b087b711..e5943772 100644 --- a/Compilation/RuntimeEmitter.cs +++ b/Compilation/RuntimeEmitter.cs @@ -67,6 +67,11 @@ public EmittedRuntime EmitAll(ModuleBuilder moduleBuilder, RuntimeFeatureSet fea // can ldtoken the type for the IsDefined read in AdjustArgs caching. (#640) EmitPadUndefinedAttribute(moduleBuilder, runtime); + // Marker attribute for "this method's first parameter is the synthetic `__this` receiver". + // Defined+created before EmitTSFunctionClass so the ctor IL can ldtoken the type for the + // IsDefined read that backstops the (ref-asm-fragile) parameter-name check. (#738) + EmitExpectsThisAttribute(moduleBuilder, runtime); + // Emit TSFunction class first (other methods depend on it) EmitTSFunctionClass(moduleBuilder, runtime); diff --git a/Compilation/StateMachineEmitHelpers.cs b/Compilation/StateMachineEmitHelpers.cs index 99d7b1b7..17f21433 100644 --- a/Compilation/StateMachineEmitHelpers.cs +++ b/Compilation/StateMachineEmitHelpers.cs @@ -807,7 +807,21 @@ public void EmitNullishCoalescing(Action emitLeft, Action emitRight) emitLeft(); EnsureBoxed(); _il.Emit(OpCodes.Dup); - _il.Emit(OpCodes.Brfalse, rightLabel); + _il.Emit(OpCodes.Brfalse, rightLabel); // CLR null → use right + + // The `$Undefined` sentinel is a non-null reference, so `brfalse` does not catch it — test + // it explicitly so `undefined ?? right` evaluates the right side. Without this, a state + // machine (generator/async) treated the sentinel as a present value and returned it, which a + // value omitted from an optional parameter now produces (an omitted arg pads `$Undefined`, + // not null) — manifesting e.g. as an infinite `while (x.length) yield* this.pop()` when + // `pop(error)` does `error ?? stack.pop()`. Mirrors the non-state-machine ILEmitter override. + if (_runtime?.UndefinedType != null) + { + _il.Emit(OpCodes.Dup); + _il.Emit(OpCodes.Isinst, _runtime.UndefinedType); + _il.Emit(OpCodes.Brtrue, rightLabel); // undefined → use right + } + _il.Emit(OpCodes.Br, endLabel); MarkLabel(rightLabel); diff --git a/SharpTS.Tests/CompilerTests/ClassMethodDefaultParameterTests.cs b/SharpTS.Tests/CompilerTests/ClassMethodDefaultParameterTests.cs index c8ac3ac6..1b82c0f9 100644 --- a/SharpTS.Tests/CompilerTests/ClassMethodDefaultParameterTests.cs +++ b/SharpTS.Tests/CompilerTests/ClassMethodDefaultParameterTests.cs @@ -4,15 +4,21 @@ namespace SharpTS.Tests.CompilerTests; /// -/// #705: in compiled mode, default parameters on class-declaration members were never applied — -/// the body emitters skipped the runtime entry prologue entirely, and value-type-defaulted params -/// kept an unboxed slot that cannot hold the undefined sentinel. This pins the fixes for the -/// non-virtual members from the issue's repros — constructors, private methods, and free -/// functions — whose value-type defaults are now widened to an object slot so an omitted or explicit -/// undefined argument fires the default (omit → default, explicit undefined → default, present -/// → used). Reference-type defaults on (virtual) instance / static methods also fire now, override-safely -/// (no slot change). Value-type defaults on virtual instance/static methods remain a tracked follow-up -/// (widening their slot would break override matching), and are intentionally not asserted here. +/// #705/#723/#737/#739: in compiled mode, default/optional parameters on class-declaration members +/// were mishandled — the body emitters skipped the runtime entry prologue, value-type-defaulted +/// params kept an unboxed slot that cannot hold the undefined sentinel, and direct calls +/// padded omitted optionals with CLR null instead of undefined. This pins the fixes: +/// +/// #705/#723: value-type defaults fire (omit → default, explicit undefined → +/// default, present → used) for free functions, constructors, private methods, and now instance +/// and static methods — the latter via value-type slot widening (static: direct; instance: +/// hierarchy-consistent, so override matching is preserved). +/// #737: defaults also fire on generator and async-generator methods (their +/// state machines now run a default-parameter prologue). +/// #739: a direct instance/static method or constructor call pads an omitted trailing +/// optional (no-default) param with the undefined sentinel, so typeof reads +/// "undefined" (not "object"). +/// /// All run in both modes to pin interpreter/compiler parity. /// public class ClassMethodDefaultParameterTests @@ -240,6 +246,170 @@ public void Override_BothDefault_DispatchesToDerived(ExecutionMode mode) Assert.Equal("D:D\n", TestHarness.Run(source, mode)); } + // ---- Value-type defaults on (virtual) instance & static methods (#723/#737) ---- + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void InstanceMethod_NumericDefault_Omitted(ExecutionMode mode) + { + var source = """ + class C { add(a: number, b: number = 10): number { return a + b; } } + console.log(new C().add(5)); + """; + Assert.Equal("15\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void InstanceMethod_NumericDefault_ExplicitUndefined(ExecutionMode mode) + { + var source = """ + class C { add(a: number, b: number = 10): number { return a + b; } } + console.log(new C().add(5, undefined)); + """; + Assert.Equal("15\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void StaticMethod_NumericDefault_Omitted(ExecutionMode mode) + { + var source = """ + class C { static add(a: number, b: number = 10): number { return a + b; } } + console.log(C.add(5)); + console.log(C.add(5, undefined)); + """; + Assert.Equal("15\n15\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void InstanceMethod_BooleanDefault_Omitted(ExecutionMode mode) + { + var source = """ + class C { flag(on: boolean = true): boolean { return on; } } + console.log(new C().flag()); + console.log(new C().flag(false)); + """; + Assert.Equal("true\nfalse\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Override_DerivedAddsDefault_OmittedFiresDerivedDefault(ExecutionMode mode) + { + // The derived override adds a value-type default the base declares as required; calling it + // with the argument omitted must fire the derived default. The hierarchy-consistent widening + // makes both `m` slots `object`, so the derived prologue can observe `undefined`. (#737) + var source = """ + class B { m(x: number): number { return x; } } + class D extends B { m(x: number = 5): number { return x * 2; } } + console.log(new D().m()); + """; + Assert.Equal("10\n", TestHarness.Run(source, mode)); + } + + // ---- Generator / async-generator method defaults (#737) ---- + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void GeneratorMethod_NumericDefault_Omitted(ExecutionMode mode) + { + var source = """ + class C { *gen(a: number, b: number = 10): Generator { yield a; yield b; } } + console.log([...new C().gen(1)].join(",")); + console.log([...new C().gen(1, 2)].join(",")); + console.log([...new C().gen(1, undefined)].join(",")); + """; + Assert.Equal("1,10\n1,2\n1,10\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void FreeGenerator_NumericDefault_Omitted(ExecutionMode mode) + { + var source = """ + function* gen(a: number, b: number = 7): Generator { yield a; yield b; } + console.log([...gen(3)].join(",")); + """; + Assert.Equal("3,7\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncGeneratorMethod_NumericDefault_Omitted(ExecutionMode mode) + { + var source = """ + class C { async *agen(a: number, b: number = 3): AsyncGenerator { yield a; yield b; } } + (async () => { + const out: number[] = []; + for await (const v of new C().agen(100)) out.push(v); + console.log(out.join(",")); + })(); + """; + Assert.Equal("100,3\n", TestHarness.Run(source, mode)); + } + + // ---- #739: direct call pads omitted optional with `undefined`, not null ---- + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void InstanceMethod_OmittedOptional_IsUndefined(ExecutionMode mode) + { + var source = """ + class C { m(x?: any): string { return typeof x; } } + console.log(new C().m()); + """; + Assert.Equal("undefined\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void StaticMethod_OmittedOptional_IsUndefined(ExecutionMode mode) + { + var source = """ + class C { static s(x?: any): string { return typeof x; } } + console.log(C.s()); + """; + Assert.Equal("undefined\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Constructor_OmittedOptional_IsUndefined(ExecutionMode mode) + { + var source = """ + class C { kind: string; constructor(x?: any) { this.kind = typeof x; } } + console.log(new C().kind); + """; + Assert.Equal("undefined\n", TestHarness.Run(source, mode)); + } + + // ---- Generator `??` over an omitted optional (regression: #739 padding exposed a latent + // state-machine nullish-coalescing bug that ignored the `$Undefined` sentinel) ---- + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void GeneratorNullishCoalescing_OmittedOptional_EvaluatesRight(ExecutionMode mode) + { + // `pop(error?)` does `error ?? this.arr.pop()`. With `error` omitted (→ `undefined`), the + // right side MUST run so the stack drains and the driving `while` terminates — otherwise the + // generator spins forever (the real-world yaml-parser hang). The compiled state-machine `??` + // previously caught only CLR null, not the `$Undefined` sentinel. + var source = """ + class P { + arr: number[] = [1, 2, 3]; + *pop(error?: any): Generator { + const token = error ?? this.arr.pop(); + yield "t" + token; + } + *drain(): Generator { while (this.arr.length > 0) yield* this.pop(); } + } + console.log([...new P().drain()].join(",")); + """; + Assert.Equal("t3,t2,t1\n", TestHarness.Run(source, mode)); + } + // ---- IL verification guard --------------------------------------------- [Fact] @@ -260,4 +430,26 @@ class D { constructor(public age: number = 99) {} } var errors = TestHarness.CompileAndVerifyOnly(source); Assert.Empty(errors); } + + [Fact] + public void InstanceStaticAndGeneratorDefaults_ProduceVerifiableIL() + { + // Widening value-type defaults on instance (hierarchy-consistent) and static methods, + // padding omitted optionals with the undefined sentinel, and the generator/async-generator + // default prologues must all emit verifiable IL. + var source = """ + class B { m(x: number): number { return x; } } + class D extends B { m(x: number = 5): number { return x * 2; } } + class C { + add(a: number, b: number = 10): number { return a + b; } + static st(a: number, b: number = 2): number { return a + b; } + opt(x?: any): string { return typeof x; } + *gen(a: number, b: number = 1): Generator { yield a; yield b; } + async *agen(a: number, b: number = 1): AsyncGenerator { yield a; yield b; } + } + console.log(new D().m() + C.st(1) + new C().add(1) + new C().opt() + [...new C().gen(1)].length); + """; + var errors = TestHarness.CompileAndVerifyOnly(source); + Assert.Empty(errors); + } } diff --git a/SharpTS.Tests/CompilerTests/ReferenceAssemblyTests.cs b/SharpTS.Tests/CompilerTests/ReferenceAssemblyTests.cs index 610abc8e..79ea1c9a 100644 --- a/SharpTS.Tests/CompilerTests/ReferenceAssemblyTests.cs +++ b/SharpTS.Tests/CompilerTests/ReferenceAssemblyTests.cs @@ -12,6 +12,35 @@ namespace SharpTS.Tests.CompilerTests; /// public class ReferenceAssemblyTests { + /// + /// #738: a function expression invoked as a value must not shift its arguments under --ref-asm. + /// The reference-assembly rewrite strips parameter names, which used to break the runtime + /// params[0].Name == "__this" receiver-slot detection — so a value-call mapped the first + /// real argument onto the synthetic __this slot, shifting everything by one (e.g. + /// f(4) returned the default 3 instead of 7). The $ExpectsThis marker + /// attribute survives the rewrite and restores correct detection. + /// + [Fact] + public void RefAsm_FunctionExpressionValueCall_DoesNotShiftArguments() + { + var source = """ + const f = function (x: number, y: number = 3) { return x + y; }; + console.log(f(4)); + console.log(f(4, 10)); + """; + + var (tempDir, dllPath) = TestHarness.CompileWithRefAsm(source); + try + { + var output = TestHarness.ExecuteCompiledDll(dllPath); + Assert.Equal("7\n14\n", output.Replace("\r\n", "\n")); + } + finally + { + CleanupTempDir(tempDir); + } + } + /// /// Verifies that an async function compiled with --ref-asm references System.Runtime. /// diff --git a/TypeSystem/TypeMap.cs b/TypeSystem/TypeMap.cs index d2a15a48..edf982ac 100644 --- a/TypeSystem/TypeMap.cs +++ b/TypeSystem/TypeMap.cs @@ -36,6 +36,13 @@ public class TypeMap /// public TypeInfo.Class? GetClassType(string className) => _classTypes.GetValueOrDefault(className); + /// + /// All registered class types, keyed by (simple) class name. Used to walk the inheritance graph + /// — e.g. to find every override of a method so the compiler can give them a hierarchy-consistent + /// CLR signature (override-safe value-type default-parameter widening, #737). + /// + public IReadOnlyDictionary ClassTypes => _classTypes; + /// /// Registers a class expression type by expression reference for IL compiler lookup. ///