diff --git a/Compilation/AsyncArrowMoveNextEmitter.cs b/Compilation/AsyncArrowMoveNextEmitter.cs index 77657fd0..1d32047a 100644 --- a/Compilation/AsyncArrowMoveNextEmitter.cs +++ b/Compilation/AsyncArrowMoveNextEmitter.cs @@ -128,6 +128,9 @@ public AsyncArrowMoveNextEmitter( /// Emits the complete MoveNext method body. /// public void EmitMoveNext(List body, CompilationContext ctx, Type returnType) + => EmitMoveNext(body, ctx, returnType, null); + + public void EmitMoveNext(List body, CompilationContext ctx, Type returnType, List? parameters) { // Note: _il is initialized in constructor via GetILGenerator() _ctx = ctx; @@ -155,6 +158,16 @@ public void EmitMoveNext(List body, CompilationContext ctx, Type returnTyp // State dispatch switch EmitStateDispatch(); + // Apply parameter defaults on initial entry (#646). Placed after the state-dispatch + // switch so a resume (state >= 0) jumps straight to its await label past this code; on + // initial entry (state -1) the switch falls through to here. Arrow parameter fields are + // always object-typed (AsyncArrowStateMachineBuilder), so the null / $Undefined check + // is valid — unlike the sync-arrow path, no slot widening is needed. + if (parameters != null) + { + EmitDefaultParameters(parameters); + } + // Emit the body foreach (var stmt in body) { @@ -196,6 +209,56 @@ private void EmitStateDispatch() _il.MarkLabel(defaultLabel); } + /// + /// Applies parameter defaults at async-arrow entry (#646). For each parameter with a default + /// value, if its hoisted state-machine field holds null or the $Undefined sentinel, the + /// default expression is evaluated and stored. Mirrors the async-function path + /// (); parameter fields are object-typed so the null / + /// $Undefined check is sound. Invoked only on initial entry (see EmitMoveNext placement), + /// so no defaults-applied guard field is required. + /// + private void EmitDefaultParameters(List parameters) + { + foreach (var param in parameters) + { + if (param.DefaultValue == null) continue; + + var field = _builder.GetVariableField(param.Name.Lexeme); + if (field == null) continue; // parameter not hoisted + + var applyDefault = _il.DefineLabel(); + var checkUndefined = _il.DefineLabel(); + var skipDefault = _il.DefineLabel(); + + // Load the parameter field value. + _il.Emit(OpCodes.Ldarg_0); + _il.Emit(OpCodes.Ldfld, field); + + // null -> apply default; otherwise test for the $Undefined sentinel. + _il.Emit(OpCodes.Dup); + _il.Emit(OpCodes.Brtrue, checkUndefined); + _il.Emit(OpCodes.Pop); + _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); + + // field = + _il.MarkLabel(applyDefault); + EmitExpression(param.DefaultValue); + EnsureBoxed(); + var temp = _il.DeclareLocal(typeof(object)); + _il.Emit(OpCodes.Stloc, temp); + _il.Emit(OpCodes.Ldarg_0); + _il.Emit(OpCodes.Ldloc, temp); + _il.Emit(OpCodes.Stfld, field); + + _il.MarkLabel(skipDefault); + } + } + #region StatementEmitterBase Overrides protected override FieldBuilder? GetHoistedVariableField(string name) => _builder.GetVariableField(name); diff --git a/Compilation/EmittedRuntime.cs b/Compilation/EmittedRuntime.cs index 3d018531..7cfe1b2a 100644 --- a/Compilation/EmittedRuntime.cs +++ b/Compilation/EmittedRuntime.cs @@ -265,6 +265,15 @@ public class EmittedRuntime public TypeBuilder CapturesArgumentsAttrType { get; set; } = null!; public ConstructorBuilder CapturesArgumentsAttrCtor { get; set; } = null!; + // Marker attribute applied to USER TypeScript function methods (declarations, + // arrows/function expressions, methods, async stubs). When the wrapped method + // carries it, $TSFunction.AdjustArgs pads omitted trailing arguments with the + // `undefined` sentinel ($Undefined.Instance) instead of CLR null, matching JS + // semantics and the direct-call path. Runtime built-ins stay unmarked and keep + // null padding (their bodies use null-checks for optional-arg absence). (#640) + public TypeBuilder PadUndefinedAttrType { get; set; } = null!; + public ConstructorBuilder PadUndefinedAttrCtor { get; set; } = null!; + // String methods public MethodBuilder StringCharAt { get; set; } = null!; public MethodBuilder StringSubstring { get; set; } = null!; diff --git a/Compilation/ILCompiler.ArrowFunctions.cs b/Compilation/ILCompiler.ArrowFunctions.cs index 159840bd..23519691 100644 --- a/Compilation/ILCompiler.ArrowFunctions.cs +++ b/Compilation/ILCompiler.ArrowFunctions.cs @@ -157,6 +157,11 @@ private void FinalizeArrowFunctionCollection() var resolvedParamTypes = ParameterTypeResolver.ResolveParameters( arrow.Parameters, _typeMapper, null, _typeMap); // null funcType - use annotations only + // Arrows/function expressions get no OverloadGenerator forwarding, so a parameter + // default is applied by the runtime entry prologue, which needs an object slot to + // detect the missing/undefined argument (#646). + ParameterTypeResolver.WidenDefaultedParamsToObject(resolvedParamTypes, arrow.Parameters, _types.Object); + // Store resolved types for use during arrow body emission _closures.ArrowParameterTypes[arrow] = resolvedParamTypes; @@ -403,6 +408,10 @@ private void FinalizeArrowFunctionCollection() _closures.DisplayClasses[arrow] = displayClass; _closures.ArrowMethods[arrow] = invokeMethod; } + + // User arrow / function-expression body: when invoked as a value, omitted trailing + // args must pad with the `undefined` sentinel (JS semantics), not CLR null. (#640) + MarkPadsUndefined(_closures.ArrowMethods[arrow]); } } diff --git a/Compilation/ILCompiler.Async.cs b/Compilation/ILCompiler.Async.cs index ab9c9bf7..143e4719 100644 --- a/Compilation/ILCompiler.Async.cs +++ b/Compilation/ILCompiler.Async.cs @@ -170,6 +170,7 @@ private void DefineAsyncArrowStateMachines( // Define the stub method that will be called to invoke the async arrow arrowBuilder.DefineStubMethod(_programType); + MarkPadsUndefined(arrowBuilder.StubMethod); // #640 _async.ArrowBuilders[arrowInfo.Arrow] = arrowBuilder; continue; // Already handled the full setup @@ -185,6 +186,7 @@ private void DefineAsyncArrowStateMachines( // Define the stub method that will be called to invoke the async arrow arrowBuilder.DefineStubMethod(_programType); + MarkPadsUndefined(arrowBuilder.StubMethod); // #640 _async.ArrowBuilders[arrowInfo.Arrow] = arrowBuilder; } @@ -656,7 +658,7 @@ private void EmitAsyncArrowMoveNext(AsyncArrowStateMachineBuilder arrowBuilder, bodyStatements = []; } - arrowEmitter.EmitMoveNext(bodyStatements, ctx, _types.Object); + arrowEmitter.EmitMoveNext(bodyStatements, ctx, _types.Object, arrow.Parameters); ILLabelValidator.Validate(arrowBuilder.MoveNextMethod.GetILGenerator(), $"async arrow MoveNext"); } @@ -956,7 +958,7 @@ private void EmitAsyncMethodBody(MethodBuilder methodBuilder, Stmt.Function meth false, // UsesThis [] // AsyncArrows - handled separately via _async.ArrowBuilders ), _types); - arrowEmitter.EmitMoveNext(bodyStatements, ctx, _types.Object); + arrowEmitter.EmitMoveNext(bodyStatements, ctx, _types.Object, arrow.Parameters); ILLabelValidator.Validate(arrowBuilder.MoveNextMethod.GetILGenerator(), $"async arrow MoveNext in {methodBuilder.DeclaringType?.Name}::{method.Name.Lexeme}"); } @@ -1009,6 +1011,7 @@ private void DefineTopLevelAsyncArrows() // Define the stub method arrowBuilder.DefineStubMethod(_programType); + MarkPadsUndefined(arrowBuilder.StubMethod); // #640 // Store the builder _async.ArrowBuilders[arrow] = arrowBuilder; @@ -1130,7 +1133,7 @@ private void EmitTopLevelAsyncArrowBodies() bodyStatements = []; } - arrowEmitter.EmitMoveNext(bodyStatements, ctx, _types.Object); + arrowEmitter.EmitMoveNext(bodyStatements, ctx, _types.Object, arrow.Parameters); ILLabelValidator.Validate(arrowBuilder.MoveNextMethod.GetILGenerator(), $"standalone async arrow MoveNext"); diff --git a/Compilation/ILCompiler.Classes.Static.cs b/Compilation/ILCompiler.Classes.Static.cs index eb1d2f47..761899f4 100644 --- a/Compilation/ILCompiler.Classes.Static.cs +++ b/Compilation/ILCompiler.Classes.Static.cs @@ -633,7 +633,7 @@ private void EmitStaticAsyncMethodBody(string className, Stmt.Function method) false, // UsesThis [] // AsyncArrows - handled separately via _async.ArrowBuilders ), _types); - arrowEmitter.EmitMoveNext(bodyStatements, ctx, _types.Object); + arrowEmitter.EmitMoveNext(bodyStatements, ctx, _types.Object, arrow.Parameters); } } diff --git a/Compilation/ILCompiler.Functions.cs b/Compilation/ILCompiler.Functions.cs index f428ffc4..07019f9e 100644 --- a/Compilation/ILCompiler.Functions.cs +++ b/Compilation/ILCompiler.Functions.cs @@ -102,6 +102,10 @@ private void DefineFunction(Stmt.Function funcStmt) _functions.Builders[qualifiedFunctionName] = methodBuilder; + // User TS function: when invoked as a value, omitted trailing args must pad with the + // `undefined` sentinel (JS semantics), not CLR null. (#640) + MarkPadsUndefined(methodBuilder); + // Flag eagerly (phase 3) so direct-call sites emitted in phase 7 can publish // caller args to the thread-static before OpCodes.Call. Uses the same scanner // the prologue consults, keeping the two sides in sync. Overload signatures @@ -1412,6 +1416,20 @@ private void EmitFunctionOverloads(Stmt.Function funcStmt) } } + /// + /// Marks a user TypeScript function method with the $PadUndefined attribute so that + /// $TSFunction.AdjustArgs pads omitted trailing arguments with the undefined + /// sentinel (JS semantics) when the function is invoked as a value (cross-module imports, + /// callbacks, $TSFunction.Invoke) — matching the direct-call path. Runtime built-ins + /// stay unmarked and keep CLR-null padding. No-op when the runtime attribute is unavailable. (#640) + /// + internal void MarkPadsUndefined(MethodBuilder method) + { + if (_runtime?.PadUndefinedAttrCtor != null) + method.SetCustomAttribute( + new System.Reflection.Emit.CustomAttributeBuilder(_runtime.PadUndefinedAttrCtor, [])); + } + /// /// Gets the index of the first parameter with a default value. /// Returns -1 if no default parameters exist. diff --git a/Compilation/ILCompiler.InnerFunctions.cs b/Compilation/ILCompiler.InnerFunctions.cs index 051bcfb6..4cfb7215 100644 --- a/Compilation/ILCompiler.InnerFunctions.cs +++ b/Compilation/ILCompiler.InnerFunctions.cs @@ -415,6 +415,10 @@ private void DefineInnerFunctions() _innerFunctionDisplayClasses[func] = displayClass; _innerFunctionMethods[func] = invokeMethod; } + + // User inner-function body: when invoked as a value, omitted trailing args must + // pad with the `undefined` sentinel (JS semantics), not CLR null. (#640) + MarkPadsUndefined(_innerFunctionMethods[func]); } } diff --git a/Compilation/ParameterTypeResolver.cs b/Compilation/ParameterTypeResolver.cs index ea45d4c3..0365fe8b 100644 --- a/Compilation/ParameterTypeResolver.cs +++ b/Compilation/ParameterTypeResolver.cs @@ -83,6 +83,28 @@ public static Type[] ResolveParameters( .ToArray(); } + /// + /// Widens any parameter that has a default value to an object slot. Required for + /// function kinds that apply parameter defaults via the runtime entry prologue + /// () rather than via + /// lower-arity forwarding — i.e. arrow functions and function expressions, which get no overloads. + /// The prologue detects a missing/undefined argument by comparing the slot against null and the + /// $Undefined sentinel, and value-call padding ($TSFunction.AdjustArgs) fills omitted + /// slots with that sentinel. Only an object slot can hold it: a value-type slot can never be + /// null (and emits invalid ldarg; brfalse IL), and a typed reference slot (e.g. string) + /// coerces the sentinel to a real value (e.g. the string "undefined") before the prologue can + /// observe it. Either way the default could never fire. Mirrors the optional-without-default widening + /// already applied in . (#646) + /// + public static void WidenDefaultedParamsToObject(Type[] resolved, List parameters, Type objectType) + { + for (int i = 0; i < parameters.Count && i < resolved.Length; i++) + { + if (parameters[i].DefaultValue != null && resolved[i] != objectType) + resolved[i] = objectType; + } + } + /// /// #372: widen a number/boolean parameter slot (unboxed double/bool) to /// object when the type checker flagged it as possibly holding the runtime undefined diff --git a/Compilation/RuntimeEmitter.TSFunction.cs b/Compilation/RuntimeEmitter.TSFunction.cs index 177999cb..26f4e656 100644 --- a/Compilation/RuntimeEmitter.TSFunction.cs +++ b/Compilation/RuntimeEmitter.TSFunction.cs @@ -56,6 +56,33 @@ private void EmitCapturesArgumentsAttribute(ModuleBuilder moduleBuilder, Emitted typeBuilder.CreateType(); } + /// + /// Emits a minimal marker attribute $PadUndefined (empty + /// subclass). Applied to user TypeScript function + /// methods (declarations, arrows/function expressions, methods, async stubs); read + /// back via at + /// $TSFunction construction to decide whether AdjustArgs pads omitted + /// trailing arguments with the undefined sentinel rather than CLR null (#640). + /// Lives in the output assembly so the compiled DLL stays standalone. + /// + private void EmitPadUndefinedAttribute(ModuleBuilder moduleBuilder, EmittedRuntime runtime) + { + var typeBuilder = moduleBuilder.DefineType( + "$PadUndefined", + 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.PadUndefinedAttrCtor = ctor; + runtime.PadUndefinedAttrType = typeBuilder; + typeBuilder.CreateType(); + } + private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime runtime) { // Define class: public sealed class $TSFunction @@ -88,6 +115,16 @@ private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime run // skip-index-box detection can read it without a method call. var capturesArgumentsField = typeBuilder.DefineField("_capturesArguments", _types.Boolean, FieldAttributes.Public); runtime.TSFunctionCapturesArgumentsField = capturesArgumentsField; + // Bitmask of parameter positions that AdjustArgs pads with the `undefined` + // sentinel (instead of CLR null) when the argument is omitted. Bit i is set iff + // the wrapped method carries the $PadUndefined marker (i.e. is a user TS function) + // AND parameter i has an `object` slot. Object slots can hold the sentinel, so + // `typeof`/`=== undefined`/default-firing all work; a typed slot (string/double) + // would coerce the sentinel to a real value before the entry prologue, so those + // stay null-padded and fire their default via the null check. Built-ins have an + // all-zero mask and keep pure null padding. Positions >= 32 are not tracked + // (such arity is never reached in practice) and pad null. (#640) + var padUndefinedMaskField = typeBuilder.DefineField("_padUndefinedMask", _types.Int32, FieldAttributes.Private); // Cached MethodInvoker. .NET 8+'s MethodInvoker.Create() pre-builds // the JIT'd dispatch stub for a method, then Invoke(...) calls it // directly — measured ~10× faster than MethodInfo.Invoke per call. @@ -234,6 +271,8 @@ private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime run EmitComputeExpectsThis(ctorIL, expectsThisField, methodArgIndex: 2); // this._capturesArguments = method.IsDefined($CapturesArguments) EmitComputeCapturesArguments(ctorIL, capturesArgumentsField, runtime, methodArgIndex: 2); + // this._padUndefinedMask = $PadUndefined ? (object-param bits) : 0 + EmitComputePadUndefinedMask(ctorIL, padUndefinedMaskField, runtime, methodArgIndex: 2); // this._paramCount, _hasListRest, _hasArrayRest: cached by AdjustArgs. EmitComputeAdjustArgsCache(ctorIL, paramCountField, hasListRestField, hasArrayRestField, methodArgIndex: 2); EmitComputeNeedsArgConversion(ctorIL, needsArgConversionField, methodArgIndex: 2); @@ -273,6 +312,7 @@ private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime run // this._expectsThis = (method.GetParameters().Length > 0 && params[0].Name == "__this") EmitComputeExpectsThis(ctorCacheIL, expectsThisField, methodArgIndex: 2); EmitComputeCapturesArguments(ctorCacheIL, capturesArgumentsField, runtime, methodArgIndex: 2); + EmitComputePadUndefinedMask(ctorCacheIL, padUndefinedMaskField, runtime, methodArgIndex: 2); EmitComputeAdjustArgsCache(ctorCacheIL, paramCountField, hasListRestField, hasArrayRestField, methodArgIndex: 2); EmitComputeNeedsArgConversion(ctorCacheIL, needsArgConversionField, methodArgIndex: 2); // this._invoker = LookupOrAdd(_invokerCache, method) @@ -356,7 +396,7 @@ private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime run gmIL.Emit(OpCodes.Ret); // Helper method: private static object[] AdjustArgs(MethodInfo method, object[] args) - var adjustArgsMethod = EmitTSFunctionAdjustArgsHelper(typeBuilder, runtime, paramCountField, hasListRestField, hasArrayRestField); + var adjustArgsMethod = EmitTSFunctionAdjustArgsHelper(typeBuilder, runtime, paramCountField, hasListRestField, hasArrayRestField, padUndefinedMaskField); // Helper method: private static void ConvertArgsForUnionTypes(MethodInfo method, object[] args) var convertArgsMethod = EmitTSFunctionConvertArgsHelper(typeBuilder, runtime); @@ -1285,6 +1325,91 @@ private void EmitComputeCapturesArguments(ILGenerator il, FieldBuilder capturesA il.Emit(OpCodes.Stfld, capturesArgumentsField); } + /// + /// Emits computation of this._padUndefinedMask. Zero unless the method carries the + /// $PadUndefined marker (i.e. is a user TS function), in which case bit i is set + /// for every parameter i < 32 whose slot is object. AdjustArgs pads only + /// those positions with the undefined sentinel; typed slots and built-ins keep null + /// padding. See . (#640) + /// + private void EmitComputePadUndefinedMask(ILGenerator il, FieldBuilder padUndefinedMaskField, EmittedRuntime runtime, int methodArgIndex) + { + var done = il.DefineLabel(); + + // if (!method.IsDefined($PadUndefined, false)) leave mask at its zero-init value. + il.Emit(OpCodes.Ldarg, methodArgIndex); + il.Emit(OpCodes.Ldtoken, runtime.PadUndefinedAttrType); + 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.Brfalse, done); + + var paramsLocal = il.DeclareLocal(_types.MakeArrayType(_types.ParameterInfo)); + var iLocal = il.DeclareLocal(_types.Int32); + var maskLocal = il.DeclareLocal(_types.Int32); + var nLocal = il.DeclareLocal(_types.Int32); + + // ps = method.GetParameters(); n = ps.Length; mask = 0; i = 0; + il.Emit(OpCodes.Ldarg, methodArgIndex); + il.Emit(OpCodes.Callvirt, _types.MethodInfo.GetMethod("GetParameters")!); + il.Emit(OpCodes.Stloc, paramsLocal); + il.Emit(OpCodes.Ldloc, paramsLocal); + il.Emit(OpCodes.Ldlen); + il.Emit(OpCodes.Conv_I4); + il.Emit(OpCodes.Stloc, nLocal); + il.Emit(OpCodes.Ldc_I4_0); + il.Emit(OpCodes.Stloc, maskLocal); + il.Emit(OpCodes.Ldc_I4_0); + il.Emit(OpCodes.Stloc, iLocal); + + var loopBody = il.DefineLabel(); + var loopCond = il.DefineLabel(); + var nextIter = il.DefineLabel(); + var storeMask = il.DefineLabel(); + il.Emit(OpCodes.Br, loopCond); + + il.MarkLabel(loopBody); + // if (ps[i].ParameterType != typeof(object)) goto nextIter; + il.Emit(OpCodes.Ldloc, paramsLocal); + il.Emit(OpCodes.Ldloc, iLocal); + il.Emit(OpCodes.Ldelem_Ref); + il.Emit(OpCodes.Callvirt, _types.ParameterInfo.GetProperty("ParameterType")!.GetGetMethod()!); + il.Emit(OpCodes.Ldtoken, _types.Object); + il.Emit(OpCodes.Call, _types.Type.GetMethod("GetTypeFromHandle")!); + il.Emit(OpCodes.Call, _types.Type.GetMethod("op_Equality", [_types.Type, _types.Type])!); + il.Emit(OpCodes.Brfalse, nextIter); + // mask |= (1 << i); + il.Emit(OpCodes.Ldloc, maskLocal); + il.Emit(OpCodes.Ldc_I4_1); + il.Emit(OpCodes.Ldloc, iLocal); + il.Emit(OpCodes.Shl); + il.Emit(OpCodes.Or); + il.Emit(OpCodes.Stloc, maskLocal); + + il.MarkLabel(nextIter); + il.Emit(OpCodes.Ldloc, iLocal); + il.Emit(OpCodes.Ldc_I4_1); + il.Emit(OpCodes.Add); + il.Emit(OpCodes.Stloc, iLocal); + + il.MarkLabel(loopCond); + // while (i < n && i < 32) + il.Emit(OpCodes.Ldloc, iLocal); + il.Emit(OpCodes.Ldloc, nLocal); + il.Emit(OpCodes.Bge, storeMask); + il.Emit(OpCodes.Ldloc, iLocal); + il.Emit(OpCodes.Ldc_I4, 32); + il.Emit(OpCodes.Blt, loopBody); + + il.MarkLabel(storeMask); + // this._padUndefinedMask = mask; + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldloc, maskLocal); + il.Emit(OpCodes.Stfld, padUndefinedMaskField); + + il.MarkLabel(done); + } + /// /// Emits an instance helper method $TSFunction.AdjustArgs(object[] args) /// that adjusts argument arrays for rest parameters and padding/trimming @@ -1293,7 +1418,8 @@ private void EmitComputeCapturesArguments(ILGenerator il, FieldBuilder capturesA /// + ParameterType reflection. /// private MethodBuilder EmitTSFunctionAdjustArgsHelper(TypeBuilder typeBuilder, EmittedRuntime runtime, - FieldBuilder paramCountField, FieldBuilder hasListRestField, FieldBuilder hasArrayRestField) + FieldBuilder paramCountField, FieldBuilder hasListRestField, FieldBuilder hasArrayRestField, + FieldBuilder padUndefinedMaskField) { // private object[] AdjustArgs(object[] args) // Instance method: arg0 = this, arg1 = args. paramCount and rest-shape @@ -1518,13 +1644,59 @@ private MethodBuilder EmitTSFunctionAdjustArgsHelper(TypeBuilder typeBuilder, Em il.Emit(OpCodes.Ldloc, argsLengthLocal); il.Emit(OpCodes.Call, _types.ArrayType.GetMethod("Copy", [_types.ArrayType, _types.ArrayType, _types.Int32])!); - // Missing args pad as null. NOT the $Undefined singleton: emitted - // built-ins broadly use null-checks for optional-arg absence (stream - // write/end callbacks, encodings, ...), so sentinel padding makes them - // treat the slot as a real argument. The one built-in that must - // distinguish absent (skip) from explicit JS null (throw) — value-form - // Object.create's props — gets a dedicated wrapper instead - // (ObjectCreateValueForm in RuntimeEmitter.Objects.Prototype.cs). + // Pad value for the missing trailing slots, per the cached _padUndefinedMask. + // + // A slot whose mask bit is set (user TS function + `object`-typed parameter) is filled + // with the `undefined` sentinel, matching JS semantics and the direct-call path — so + // `typeof`, `=== undefined`, and `=== null` answer correctly for an omitted optional + // parameter, and an object-slotted default-valued parameter's entry prologue fires. + // + // Every other slot is left as CLR null (the Newarr zero value). That covers built-ins + // (mask 0 — their bodies use null-checks for optional-arg absence: stream write/end + // callbacks, encodings, ...) and typed default-valued parameters of user functions + // (string/double slots can't hold the sentinel — it would coerce to a real value + // before the entry prologue, whereas null fires their default via the null check). The + // one built-in that must distinguish absent (skip) from explicit JS null (throw) — + // value-form Object.create's props — gets a dedicated wrapper instead + // (ObjectCreateValueForm in RuntimeEmitter.Objects.Prototype.cs). (#640) + var skipUndefinedPad = il.DefineLabel(); + // if (_padUndefinedMask == 0) skip — fast path for built-ins and all-typed signatures. + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, padUndefinedMaskField); + il.Emit(OpCodes.Brfalse, skipUndefinedPad); + // for (int i = argsLength; i < paramCount; i++) + // if (((_padUndefinedMask >> i) & 1) != 0) result[i] = $Undefined.Instance; + var padIdxLocal = il.DeclareLocal(_types.Int32); + var padLoopBody = il.DefineLabel(); + var padLoopCond = il.DefineLabel(); + var padSkipSlot = il.DefineLabel(); + il.Emit(OpCodes.Ldloc, argsLengthLocal); + il.Emit(OpCodes.Stloc, padIdxLocal); + il.Emit(OpCodes.Br, padLoopCond); + il.MarkLabel(padLoopBody); + // if (((mask >> i) & 1) == 0) goto next; + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, padUndefinedMaskField); + il.Emit(OpCodes.Ldloc, padIdxLocal); + il.Emit(OpCodes.Shr_Un); + il.Emit(OpCodes.Ldc_I4_1); + il.Emit(OpCodes.And); + il.Emit(OpCodes.Brfalse, padSkipSlot); + il.Emit(OpCodes.Ldloc, resultLocal); + il.Emit(OpCodes.Ldloc, padIdxLocal); + il.Emit(OpCodes.Ldsfld, runtime.UndefinedInstance); + il.Emit(OpCodes.Stelem_Ref); + il.MarkLabel(padSkipSlot); + il.Emit(OpCodes.Ldloc, padIdxLocal); + il.Emit(OpCodes.Ldc_I4_1); + il.Emit(OpCodes.Add); + il.Emit(OpCodes.Stloc, padIdxLocal); + il.MarkLabel(padLoopCond); + il.Emit(OpCodes.Ldloc, padIdxLocal); + il.Emit(OpCodes.Ldloc, paramCountLocal); + il.Emit(OpCodes.Blt, padLoopBody); + il.MarkLabel(skipUndefinedPad); + il.Emit(OpCodes.Ldloc, resultLocal); il.Emit(OpCodes.Ret); diff --git a/Compilation/RuntimeEmitter.cs b/Compilation/RuntimeEmitter.cs index 012f5dee..b087b711 100644 --- a/Compilation/RuntimeEmitter.cs +++ b/Compilation/RuntimeEmitter.cs @@ -62,6 +62,11 @@ public EmittedRuntime EmitAll(ModuleBuilder moduleBuilder, RuntimeFeatureSet fea // ldtoken the type for the IsDefined read. EmitCapturesArgumentsAttribute(moduleBuilder, runtime); + // Marker attribute for "this is a user TS function; pad omitted args with the + // `undefined` sentinel". Defined+created before EmitTSFunctionClass so the ctor IL + // can ldtoken the type for the IsDefined read in AdjustArgs caching. (#640) + EmitPadUndefinedAttribute(moduleBuilder, runtime); + // Emit TSFunction class first (other methods depend on it) EmitTSFunctionClass(moduleBuilder, runtime); diff --git a/SharpTS.Tests/CompilerTests/ArrowFunctionDefaultParameterTests.cs b/SharpTS.Tests/CompilerTests/ArrowFunctionDefaultParameterTests.cs new file mode 100644 index 00000000..73ac74c7 --- /dev/null +++ b/SharpTS.Tests/CompilerTests/ArrowFunctionDefaultParameterTests.cs @@ -0,0 +1,118 @@ +using SharpTS.Tests.Infrastructure; +using Xunit; + +namespace SharpTS.Tests.CompilerTests; + +/// +/// #646: in compiled mode, function expressions and arrow functions must honor parameter defaults +/// for omitted arguments. They have no OverloadGenerator lower-arity forwarding, so a value-type +/// default slot (e.g. number) previously emitted invalid ldarg; brfalse IL and left +/// the parameter at its CLR zero value. The fix widens defaulted arrow/function-expression params +/// to object so the runtime entry prologue applies the default; async arrows additionally now emit +/// that prologue at all. These run in both modes to pin interpreter/compiler parity. +/// +public class ArrowFunctionDefaultParameterTests +{ + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void FunctionExpression_NumericDefault_OmittedArg(ExecutionMode mode) + { + var source = """ + const f = function (x: number, y: number = 3) { return x + y; }; + console.log(f(4)); + """; + Assert.Equal("7\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Arrow_NumericDefault_OmittedArg(ExecutionMode mode) + { + var source = """ + const f = (x: number, y: number = 3) => x + y; + console.log(f(4)); + """; + Assert.Equal("7\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Arrow_NumericDefault_ArgPresent_DefaultNotApplied(ExecutionMode mode) + { + var source = """ + const f = (x: number, y: number = 3) => x + y; + console.log(f(4, 10)); + """; + Assert.Equal("14\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Arrow_BooleanDefault_OmittedArg(ExecutionMode mode) + { + var source = """ + const h = (on: boolean = true) => on ? "Y" : "N"; + console.log(h() + h(false)); + """; + Assert.Equal("YN\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Arrow_StringDefault_OmittedArg(ExecutionMode mode) + { + var source = """ + const s = (a: string, b: string = "B") => a + b; + console.log(s("A")); + """; + Assert.Equal("AB\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingArrow_NumericDefault_OmittedArg(ExecutionMode mode) + { + var source = """ + const base = 100; + const cap = (n: number = 7) => base + n; + console.log(cap() + "," + cap(1)); + """; + Assert.Equal("107,101\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncArrow_NumericDefault_OmittedArg(ExecutionMode mode) + { + var source = """ + const af = async (x: number, y: number = 3) => x + y; + af(4).then(v => console.log(v)); + """; + Assert.Equal("7\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AsyncArrow_DefaultWithAwait_OmittedArg(ExecutionMode mode) + { + var source = """ + const af = async (x: number, y: number = 9) => { await Promise.resolve(0); return x * y; }; + af(2).then(v => console.log(v)); + """; + Assert.Equal("18\n", TestHarness.Run(source, mode)); + } + + [Fact] + public void Arrow_ValueTypeDefault_ProducesVerifiableIL() + { + // Regression guard for the StackUnexpected IL error: a value-type defaulted arrow + // parameter must not emit `ldarg; brfalse` on a double slot. (Behavioral correctness + // is covered by the both-mode theory tests above via RunCompiled.) + var source = """ + const f = function (x: number, y: number = 3) { return x + y; }; + console.log(f(4)); + """; + var errors = TestHarness.CompileAndVerifyOnly(source); + Assert.Empty(errors); + } +} diff --git a/SharpTS.Tests/CompilerTests/ValueCallUndefinedPaddingTests.cs b/SharpTS.Tests/CompilerTests/ValueCallUndefinedPaddingTests.cs new file mode 100644 index 00000000..40df90ed --- /dev/null +++ b/SharpTS.Tests/CompilerTests/ValueCallUndefinedPaddingTests.cs @@ -0,0 +1,98 @@ +using SharpTS.Tests.Infrastructure; +using Xunit; + +namespace SharpTS.Tests.CompilerTests; + +/// +/// #640: in compiled mode, invoking a user function as a value (cross-module imports, callbacks, +/// $TSFunction.Invoke) must pad omitted trailing optional arguments with the undefined +/// sentinel — not CLR null — so typeof, === undefined, and === null answer +/// correctly. Runtime built-ins keep null padding (they use null-checks for optional-arg absence). +/// Both modes are pinned for interpreter/compiler parity; the compiled path was the defective one. +/// +public class ValueCallUndefinedPaddingTests +{ + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CrossModuleImport_OmittedOptionalArg_IsUndefined(ExecutionMode mode) + { + var files = new Dictionary + { + ["helper.ts"] = """ + export function h(x?: any): string { return typeof x; } + export function h2(x?: any): boolean { return x === undefined; } + export function h3(x?: any): boolean { return x === null; } + """, + ["main.ts"] = """ + import { h, h2, h3 } from './helper'; + console.log(h()); + console.log(h2()); + console.log(h3()); + """, + }; + Assert.Equal("undefined\ntrue\nfalse\n", TestHarness.RunModules(files, "main.ts", mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void FunctionDeclarationAsValue_OmittedOptionalArg_IsUndefined(ExecutionMode mode) + { + var source = """ + function fdecl(x?: any): string { return typeof x; } + const r = fdecl; + console.log(r()); + """; + Assert.Equal("undefined\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ArrowAsValue_OmittedOptionalArg_IsUndefined(ExecutionMode mode) + { + var source = """ + const farrow = (x?: any): string => typeof x; + console.log(farrow()); + """; + Assert.Equal("undefined\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Callback_OmittedOptionalArg_IsUndefined(ExecutionMode mode) + { + var source = """ + function callIt(cb: (x?: any) => string): string { return cb(); } + console.log(callIt((x?: any) => typeof x)); + """; + Assert.Equal("undefined\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void InnerFunctionAsValue_OmittedOptionalArg_IsUndefined(ExecutionMode mode) + { + var source = """ + function outer(): string { + function inner(x?: any): string { return typeof x; } + const r = inner; + return r(); + } + console.log(outer()); + """; + Assert.Equal("undefined\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void BuiltInCallback_OmittedTrailingArg_StillNullPadded(ExecutionMode mode) + { + // Regression guard: built-in array callbacks pad missing trailing slots with null + // (not the sentinel). An arrow callback only declaring the element parameter must still + // work — and a `function` callback observing arity sees the standard 3 args. + var source = """ + const doubled = [1, 2, 3].map(x => x * 2); + console.log(doubled.join(",")); + """; + Assert.Equal("2,4,6\n", TestHarness.Run(source, mode)); + } +} diff --git a/SharpTS.Tests/TypeCheckerTests/OptionalParamUndefinedArgTests.cs b/SharpTS.Tests/TypeCheckerTests/OptionalParamUndefinedArgTests.cs new file mode 100644 index 00000000..44445b4f --- /dev/null +++ b/SharpTS.Tests/TypeCheckerTests/OptionalParamUndefinedArgTests.cs @@ -0,0 +1,104 @@ +using SharpTS.Tests.Infrastructure; +using Xunit; + +namespace SharpTS.Tests.TypeCheckerTests; + +/// +/// #668: an optional (x?: T) or default-valued (x: T = ...) parameter accepts an +/// explicit undefined argument at the call site (its call-site type is T | undefined); +/// passing undefined to a default-valued parameter triggers the default. A genuinely required +/// parameter must still reject undefined. Runs ahead of both interpreter and compiler. +/// +public class OptionalParamUndefinedArgTests +{ + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void OptionalParameter_AcceptsExplicitUndefined(ExecutionMode mode) + { + var source = """ + function f(name?: string): string { return "Hi " + name; } + console.log(f(undefined)); + """; + Assert.Equal("Hi undefined\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void DefaultParameter_AcceptsExplicitUndefined_FiresDefault(ExecutionMode mode) + { + var source = """ + function g(n: string = "W"): string { return n; } + console.log(g(undefined)); + """; + Assert.Equal("W\n", TestHarness.Run(source, mode)); + } + + [Fact] + public void DefaultSecondParameter_AcceptsExplicitUndefined() + { + // Interpreter-only: the #668 type-check fix (accept explicit `undefined`) is exercised in + // BOTH modes by the optional/string-default tests above. Passing `undefined` to a *value-type* + // default of a function declaration is correct here but yields NaN in compiled mode — a + // separate value-type-slot runtime gap tracked in #705. + var source = """ + function p(a: string, b: number = 2): number { return a.length + b; } + console.log(p("x", undefined)); + """; + Assert.Equal("3\n", TestHarness.RunInterpreted(source)); + } + + [Fact] + public void RequiredParameter_RejectsUndefined() + { + // Regression guard: a genuinely required parameter must still reject `undefined` (TS2345). + var source = """ + function r(n: string): string { return n; } + console.log(r(undefined)); + """; + var ex = Assert.ThrowsAny(() => TestHarness.RunInterpreted(source)); + Assert.Contains("Type Error", ex.Message); + } + + [Fact] + public void OptionalParameter_AcceptsUnionWithUndefinedArgument() + { + // `string | undefined` value passed to an optional `string` parameter. + var source = """ + function f(name?: string): string { return typeof name; } + const v: string | undefined = (Math.random() < 0 ? "x" : undefined); + console.log(f(v)); + """; + // Type-checks (no TS2345); value is undefined at runtime. + Assert.Equal("undefined\n", TestHarness.RunInterpreted(source)); + } + + [Fact] + public void PrivateMethodDefaultParameter_AcceptsUndefined() + { + // Interpreter-only — see DefaultSecondParameter_AcceptsExplicitUndefined; value-type + // default in a method yields NaN in compiled mode (tracked in #705). + var source = """ + class C { + #priv(a: string, b: number = 7): number { return a.length + b; } + go(): number { return this.#priv("x", undefined); } + } + console.log(new C().go()); + """; + Assert.Equal("8\n", TestHarness.RunInterpreted(source)); + } + + [Fact] + public void ConstructorDefaultParameter_AcceptsUndefined() + { + // Interpreter-only — see DefaultSecondParameter_AcceptsExplicitUndefined; value-type + // default in a constructor yields NaN in compiled mode (tracked in #705). + var source = """ + class D { + constructor(public name: string, public age: number = 99) {} + } + const d = new D("Bob", undefined); + console.log(d.name + ":" + d.age); + """; + Assert.Equal("Bob:99\n", TestHarness.RunInterpreted(source)); + } +} diff --git a/TypeSystem/TypeChecker.Calls.cs b/TypeSystem/TypeChecker.Calls.cs index 6e98715e..4a0900ad 100644 --- a/TypeSystem/TypeChecker.Calls.cs +++ b/TypeSystem/TypeChecker.Calls.cs @@ -560,8 +560,10 @@ iterGet.Object is Expr.Variable iterVar && } if (paramIndex < regularParamCount) { - // Check against regular parameter - if (!IsCompatible(funcType.ParamTypes[paramIndex], argType)) + // Check against regular parameter. An optional/default-valued parameter + // (position >= MinArity) also accepts an explicit `undefined` (#668). + bool optional = paramIndex >= funcType.MinArity; + if (!IsArgumentCompatible(funcType.ParamTypes[paramIndex], argType, optional)) { throw new TypeCheckException($"Argument {argIndex + 1} expected type '{funcType.ParamTypes[paramIndex]}' but got '{argType}'.", tsCode: "TS2345"); } diff --git a/TypeSystem/TypeChecker.Compatibility.cs b/TypeSystem/TypeChecker.Compatibility.cs index 966bbc61..7b21a50e 100644 --- a/TypeSystem/TypeChecker.Compatibility.cs +++ b/TypeSystem/TypeChecker.Compatibility.cs @@ -89,6 +89,24 @@ public int GetHashCode(IdentityCompatibilityCacheKey obj) private int _compatibilityCheckDepth; private const int MaxCompatibilityCheckDepth = 50; + /// + /// Argument/parameter assignability that additionally honors parameter optionality. + /// An optional (x?: T) or default-valued (x: T = …) parameter has a call-site + /// type of T | undefined in TypeScript, so an explicit undefined (or a + /// T | undefined value) argument is accepted there — and, for a default-valued + /// parameter, triggers the default. Pass = true only for a + /// non-rest parameter whose position is at or beyond the signature's MinArity; a + /// genuinely required parameter (optional = false) still rejects undefined. (#668) + /// + private bool IsArgumentCompatible(TypeInfo paramType, TypeInfo argType, bool optional) + { + if (IsCompatible(paramType, argType)) return true; + if (!optional) return false; + // Widen the declared type with `undefined`; the existing union-compatibility rules then + // accept an `undefined` / `T | undefined` argument without admitting any other mismatch. + return IsCompatible(new TypeInfo.Union([paramType, new TypeInfo.Undefined()]), argType); + } + /// /// Checks type compatibility with two-level memoization and co-inductive cycle detection. /// Level 1: Fast identity-based cache using reference equality (O(1) for same instances) diff --git a/TypeSystem/TypeChecker.Properties.New.cs b/TypeSystem/TypeChecker.Properties.New.cs index 0a7fc1d5..af848fa5 100644 --- a/TypeSystem/TypeChecker.Properties.New.cs +++ b/TypeSystem/TypeChecker.Properties.New.cs @@ -759,7 +759,11 @@ private void ValidateConstructorCall( for (int i = 0; i < newExpr.Arguments.Count; i++) { TypeInfo argType = CheckExpr(newExpr.Arguments[i]); - if (!IsCompatible(paramTypes[i], argType)) + // Optional/default constructor params accept an explicit `undefined` (#668); + // a rest parameter's elements are not optional in that sense. + bool optional = i >= ctorType.MinArity && + !(ctorType.HasRestParam && i >= paramTypes.Count - 1); + if (!IsArgumentCompatible(paramTypes[i], argType, optional)) throw new TypeCheckException($" Constructor argument {i + 1} expected type '{paramTypes[i]}' but got '{argType}'.", tsCode: "TS2345"); } } diff --git a/TypeSystem/TypeChecker.Properties.cs b/TypeSystem/TypeChecker.Properties.cs index cafc60d0..4c8b3c75 100644 --- a/TypeSystem/TypeChecker.Properties.cs +++ b/TypeSystem/TypeChecker.Properties.cs @@ -1024,7 +1024,11 @@ private TypeInfo CheckCallPrivate(Expr.CallPrivate call) ? funcType.ParamTypes[i] : funcType.ParamTypes[^1]; // Rest parameter type - if (!IsCompatible(paramType, argType)) + // Optional/default params accept an explicit `undefined` (#668). A rest parameter's + // elements are not optional in that sense, so only widen for non-rest positions. + bool optional = i >= funcType.MinArity && + !(funcType.HasRestParam && i >= funcType.ParamTypes.Count - 1); + if (!IsArgumentCompatible(paramType, argType, optional)) { throw new TypeCheckException($" Argument {i + 1} to private method '{methodName}' has type '{argType}' but expected '{paramType}'.", tsCode: "TS2345"); }