diff --git a/Compilation/ArrowBoxedAdapterEmitter.cs b/Compilation/ArrowBoxedAdapterEmitter.cs new file mode 100644 index 00000000..005297da --- /dev/null +++ b/Compilation/ArrowBoxedAdapterEmitter.cs @@ -0,0 +1,128 @@ +using System.Reflection; +using System.Reflection.Emit; + +namespace SharpTS.Compilation; + +/// +/// Emits per-arrow "boxed adapter" static methods that let an arrow with +/// annotated parameters be dispatched by the array HOF direct-delegate +/// fast path (#861). +/// +/// +/// +/// An annotated callback like (x: number): number => x*2 compiles to a +/// static method with the typed CLR signature double(double), which cannot +/// bind to the Func<object,object> that the emitted +/// Array*Direct helpers expect. Without a bridge, such callbacks fall back +/// to the reflective $TSFunction/MethodInvoker path (per-element +/// dispatch + an object[] allocation), which is exactly the cost #861 +/// targets. +/// +/// +/// The adapter is a static method object Adapter(object[, object]) on +/// $Program whose body unboxes/casts each boxed element into the arrow's +/// typed parameter slot, calls the typed arrow method, then reboxes the +/// result. It binds to Func<object,object> / +/// Func<object,object,object> via ldnull+ldftn, so the +/// unchanged Array*Direct helpers drive it with a direct delegate call. +/// +/// +/// The unbox/box marshalling is shared with +/// ( coerces +/// object→typed slot; +/// reboxes the typed result), so the conversion matches the reflective +/// MethodInvoker semantics for the no-arg-conversion regime (concrete +/// double/bool/string params — unions/nullable already widen +/// to object in ParameterTypeResolver, and the call site gates on +/// that). Only emits a static adapter, so it carries no SharpTS.dll +/// reference (standalone-DLL constraint preserved). +/// +/// +internal sealed class ArrowBoxedAdapterEmitter +{ + // Keyed by (typed arrow method, adapter arity). Arity is the delegate's + // parameter count (1 for map/filter/forEach/find/…, 2 for reduce); the arrow + // itself may declare fewer params, in which case the extra adapter args are + // ignored. A given arrow node is emitted by exactly one CompilationContext (the + // one containing its call site), so this per-context cache never double-defines; + // the adapter NAME is derived from the arrow's globally-unique method name so it + // stays collision-free across contexts that share the same $Program type. + private readonly Dictionary<(MethodBuilder, int, bool), MethodBuilder> _cache = []; + + /// + /// Returns the boxed adapter for bound to a delegate of + /// object parameters, emitting it on + /// on first request. selects the carrier shape: + /// + /// false (#861 L1): a STATIC adapter on $Program calling the non-capturing + /// static arrow (<>Arrow_N). + /// true (#861 L3): an INSTANCE adapter on the capturing arrow's display class, calling + /// this.Invoke(...); the caller binds it to (displayInstance, ldftn adapter). + /// + /// (#861 L4): when the arrow returns bool and a + /// Func<object,bool> predicate helper is used, the adapter returns the unboxed + /// bool directly (no rebox) so the *DirectBool helper skips the box + IsTruthy. + /// + public MethodBuilder GetOrEmit(TypeBuilder carrierType, MethodBuilder arrowMethod, int funcArity, bool instance, bool boolReturn) + { + var key = (arrowMethod, funcArity, boolReturn); + if (_cache.TryGetValue(key, out var existing)) return existing; + + var objectType = typeof(object); + var adapterParams = new Type[funcArity]; + for (int i = 0; i < funcArity; i++) adapterParams[i] = objectType; + var adapterReturnType = boolReturn ? typeof(bool) : objectType; + + // Name keyed off the arrow method's name ($Program-unique <>Arrow_N for static; "Invoke" + // for instance, where the per-arrow display class scopes it) plus the return-shape marker. + // Assembly-visible. + var attrs = MethodAttributes.Assembly | (instance ? 0 : MethodAttributes.Static); + var adapter = carrierType.DefineMethod( + $"{arrowMethod.Name}${(boolReturn ? "bbox" : "box")}{funcArity}", + attrs, + adapterReturnType, + adapterParams); + + var il = adapter.GetILGenerator(); + var arrowParams = arrowMethod.GetParameters(); + // For an instance adapter arg0 is `this` (pushed first as the Invoke receiver); the delegate + // args then start at arg1. A static adapter's delegate args start at arg0. + int argBase = instance ? 1 : 0; + if (instance) + il.Emit(OpCodes.Ldarg_0); + + // Load only as many args as the arrow actually declares, coercing each boxed object into its + // typed parameter slot. A 0-/1-param arrow under a wider delegate ignores the surplus args. + for (int i = 0; i < arrowParams.Length; i++) + { + EmitLdarg(il, argBase + i); + DelegateAdapterEmitter.EmitUnboxForReturn(il, arrowParams[i].ParameterType); + } + + il.Emit(instance ? OpCodes.Callvirt : OpCodes.Call, arrowMethod); + + // bool-return: the arrow already returns bool (caller guarantees) — leave it unboxed for the + // Func contract. Otherwise rebox the typed result to object for Func. + if (!boolReturn) + DelegateAdapterEmitter.EmitBoxForTS(il, arrowMethod.ReturnType); + il.Emit(OpCodes.Ret); + + _cache[key] = adapter; + return adapter; + } + + private static void EmitLdarg(ILGenerator il, int index) + { + switch (index) + { + case 0: il.Emit(OpCodes.Ldarg_0); break; + case 1: il.Emit(OpCodes.Ldarg_1); break; + case 2: il.Emit(OpCodes.Ldarg_2); break; + case 3: il.Emit(OpCodes.Ldarg_3); break; + default: + if (index <= 255) il.Emit(OpCodes.Ldarg_S, (byte)index); + else il.Emit(OpCodes.Ldarg, index); + break; + } + } +} diff --git a/Compilation/CompilationContext.Closures.cs b/Compilation/CompilationContext.Closures.cs index b99b8631..89a0b05d 100644 --- a/Compilation/CompilationContext.Closures.cs +++ b/Compilation/CompilationContext.Closures.cs @@ -16,6 +16,13 @@ public partial class CompilationContext // Arrow function methods (arrow node -> method info) public Dictionary ArrowMethods { get; set; } = []; + // Boxed-callback adapters for annotated-param array HOF callbacks (#861). + // Lazily created (no constructor plumbing needed); emits per-arrow + // object(object[,object]) adapters on $Program so a typed arrow can bind to + // the Func the Array*Direct helpers expect. + private ArrowBoxedAdapterEmitter? _arrowBoxedAdapters; + internal ArrowBoxedAdapterEmitter ArrowBoxedAdapters => _arrowBoxedAdapters ??= new ArrowBoxedAdapterEmitter(); + // Module-scope const → literal-arrow bindings. Iterator-helper fast paths // look up `Expr.Variable` callbacks here so `const sq = x => x*x; arr.map(sq)` // gets the same direct-delegate dispatch as the inline-arrow form. diff --git a/Compilation/DelegateAdapterEmitter.cs b/Compilation/DelegateAdapterEmitter.cs index 27c9c1da..85900520 100644 --- a/Compilation/DelegateAdapterEmitter.cs +++ b/Compilation/DelegateAdapterEmitter.cs @@ -159,7 +159,13 @@ private MethodBuilder EmitInvoke( /// TS number, bool stays boxed bool, reference types pass through. /// Mirrors the interpreter's DotNetMarshaller.WrapReturn for the common primitives. /// - private void EmitBoxForTS(ILGenerator il, Type paramType) + /// + /// internal static so the boxed-callback adapter emitter for array HOFs + /// (, #861) shares the exact same + /// box/unbox conventions — keeping number↔boxed-double etc. + /// consistent across every emitted marshalling site. + /// + internal static void EmitBoxForTS(ILGenerator il, Type paramType) { if (!paramType.IsValueType) { @@ -209,7 +215,13 @@ private void EmitBoxForTS(ILGenerator il, Type paramType) /// delegate's declared return type. void: pop. object: no-op. value types: unbox via /// boxed-double (matching ). Reference types: castclass. /// - private void EmitUnboxForReturn(ILGenerator il, Type returnType) + /// + /// internal static: also used by + /// (#861) to coerce a boxed array element into an arrow's typed parameter slot + /// (the inverse role), so the unbox/cast matches the reflective + /// MethodInvoker path exactly. + /// + internal static void EmitUnboxForReturn(ILGenerator il, Type returnType) { if (returnType == typeof(void)) { diff --git a/Compilation/Emitters/ArrayEmitter.cs b/Compilation/Emitters/ArrayEmitter.cs index 9f72c797..0cc13157 100644 --- a/Compilation/Emitters/ArrayEmitter.cs +++ b/Compilation/Emitters/ArrayEmitter.cs @@ -13,46 +13,62 @@ public sealed class ArrayEmitter : ITypeEmitterStrategy /// Attempts to emit IL for a method call on an array receiver. /// public bool TryEmitMethodCall(IEmitterContext emitter, Expr receiver, string methodName, List arguments) + => TryEmitMethodCall(emitter, receiver, methodName, arguments, leaveResultAsBareList: false); + + /// + /// Core array-method emitter. is set by the chained-stage + /// fast path (#861 L2): when this call's array result flows DIRECTLY into another array-method call, + /// the returnsNewArray $Array wrap is skipped so the bare List<object> feeds + /// straight into the next stage — eliminating the wrap-then-unwrap round-trip. + /// + private bool TryEmitMethodCall(IEmitterContext emitter, Expr receiver, string methodName, List arguments, bool leaveResultAsBareList) { var ctx = emitter.Context; var il = ctx.IL; - // Emit the array object - emitter.EmitExpression(receiver); - emitter.EmitBoxIfNeeded(receiver); - - // Handle both List and $Array types - // For $Array, extract the Elements property. - // The returned local holds the ORIGINAL receiver, used below for - // identity-preserving methods (sort/reverse/fill/copyWithin) and to - // wrap "new array" method results back into $Array so downstream - // code sees a $Array whenever the input was one. - var receiverLocal = EmitGetListFromArrayOrList(il, ctx); - - // Methods whose spec says "return this" — the caller expects the same - // reference the receiver started with. Since we unwrapped to a List, - // the runtime helper returns the inner List, not the $Array wrapper; - // to preserve `arr === arr.sort()` we stash the wrapper and push it - // back at the end. + // Methods whose spec says "return this" — the caller expects the same reference the receiver + // started with (sort/reverse/fill/copyWithin). Since we unwrap to a List, the helper returns the + // inner List, not the $Array wrapper; to preserve `arr === arr.sort()` we stash the wrapper and + // push it back at the end. bool returnsReceiver = methodName is "sort" or "reverse" or "fill" or "copyWithin"; - // Methods whose spec says "return a new Array" — we want callers to - // continue seeing a $Array after them (not a bare List), so - // downstream array methods / runtime dispatch still match. - bool returnsNewArray = methodName is - "slice" or "concat" or "map" or "filter" or "flat" or "flatMap" - or "splice" or "toReversed" or "toSorted" or "toSpliced" or "with"; + // Methods whose spec says "return a new Array" — callers should keep seeing a $Array after them + // (not a bare List), so downstream array methods / runtime dispatch still match. + bool returnsNewArray = IsReturnsNewArrayMethod(methodName); // #850: when an argument can suspend (await/yield), evaluating it while the receiver list sits - // on the IL evaluation stack produces invalid IL across a state-machine suspension - // (PathStackDepth — the stack differs between the "awaiter completed" and post-resume paths). - // Pre-spill the receiver and every argument into await-safe locals (registered so they survive - // the MoveNext re-entry; plain locals in the synchronous emitter, so non-async codegen is - // unchanged), then restore the list on the stack so each case below emits exactly as before but - // sources its arguments from the pre-spilled locals. Only the plain-argument methods are - // affected; the callback methods (map/filter/find/...) dispatch their arrow from the AST and - // never trigger this — an arrow body's await belongs to its own state machine. + // on the IL evaluation stack produces invalid IL across a state-machine suspension. Pre-spill the + // receiver + args into await-safe locals (callback methods never trigger this — an arrow body's + // await belongs to its own state machine). + bool plainArgSuspension = arguments.Count > 0 && MethodTakesPlainArgs(methodName) + && emitter.ArgsContainSuspension(arguments); + + // The receiver local holds the ORIGINAL receiver, used by returnsReceiver methods and the + // suspension spill below; unused for the other methods. + LocalBuilder receiverLocal; + + // #861 L2: if the receiver is itself an array-method call producing a fresh array via THIS + // emitter (e.g. the a.map(f) in a.map(f).filter(g)), emit it leaving a bare List (its + // $Array wrap suppressed) and skip the unwrap here — killing the round-trip at the boundary. The + // intermediate array is anonymous (it can only be this one call's receiver), so dropping its + // $Array identity is unobservable. Disabled when the outer method needs the original receiver + // wrapper (returnsReceiver) or pre-spills suspending plain args (the chained receiver would sit + // on the stack across the suspension). + if (!returnsReceiver && !plainArgSuspension + && TryEmitChainedArrayReceiverAsBareList(emitter, receiver)) + { + // Bare List already on the stack; receiverLocal is never read in this path. + receiverLocal = il.DeclareLocal(ctx.Types.ListOfObject); + } + else + { + // General path: emit the array object and unwrap $Array → List. + emitter.EmitExpression(receiver); + emitter.EmitBoxIfNeeded(receiver); + receiverLocal = EmitGetListFromArrayOrList(il, ctx); + } + LocalBuilder[]? argLocals = null; - if (arguments.Count > 0 && MethodTakesPlainArgs(methodName) && emitter.ArgsContainSuspension(arguments)) + if (plainArgSuspension) { var listSafe = emitter.SpillStackToObjectLocal(); // [list] -> await-safe local il.Emit(OpCodes.Ldloc, receiverLocal); // the original receiver must also @@ -355,10 +371,40 @@ public bool TryEmitMethodCall(IEmitterContext emitter, Expr receiver, string met return false; } - EmitPostCallAdjust(il, ctx, receiverLocal, returnsReceiver, returnsNewArray); + // #861 L2: when this result flows straight into a chained array call, skip the $Array wrap and + // leave the bare List on the stack for the next stage. + if (!(leaveResultAsBareList && returnsNewArray)) + EmitPostCallAdjust(il, ctx, receiverLocal, returnsReceiver, returnsNewArray); return true; } + /// The methods whose spec result is a freshly-allocated array (kept as a $Array for callers). + private static bool IsReturnsNewArrayMethod(string methodName) => methodName is + "slice" or "concat" or "map" or "filter" or "flat" or "flatMap" + or "splice" or "toReversed" or "toSorted" or "toSpliced" or "with"; + + /// + /// #861 L2 chained-stage detection. If is a call to a fresh-array- + /// producing array method on an array receiver (the a.map(f) in a.map(f).filter(g)), + /// emit that inner call leaving a bare List<object> on the stack — its $Array wrap + /// suppressed — and return true. The intermediate array is anonymous (it can only be this one outer + /// call's receiver), so dropping its $Array identity is unobservable. Returns false (emitting + /// nothing) otherwise. + /// + private bool TryEmitChainedArrayReceiverAsBareList(IEmitterContext emitter, Expr receiver) + { + if (receiver is not Expr.Call { Optional: false, Callee: Expr.Get { Optional: false } innerGet } innerCall) + return false; + string innerMethod = innerGet.Name.Lexeme; + // Must be a returnsNewArray method handled by THIS emitter (so it leaves a List on the stack). + if (!IsReturnsNewArrayMethod(innerMethod)) return false; + // The inner call's receiver must statically be an array, so the inner call goes through this + // emitter's returnsNewArray path and the recursion leaves a List (not some other value). + if (emitter.Context.TypeMap?.Get(innerGet.Object) is not SharpTS.TypeSystem.TypeInfo.Array) + return false; + return TryEmitMethodCall(emitter, innerGet.Object, innerMethod, innerCall.Arguments, leaveResultAsBareList: true); + } + /// /// After a call that leaves a List<object?> on the stack, adjust the /// top-of-stack so downstream code sees the expected JS value: @@ -781,44 +827,130 @@ private static bool TryEmitDirectDelegateCall(IEmitterContext emitter, List 1) return false; foreach (var p in af.Parameters) { - if (p.Type != null) return false; + // Annotated params (p.Type != null) are now supported via a boxed + // adapter (#861) — see the typed-param path below. Rest/optional/ + // defaulted params still bail (they need the reflective arg shape). if (p.IsRest || p.IsOptional) return false; if (p.DefaultValue != null) return false; } if (!ctx.ArrowMethods.TryGetValue(af, out var staticMethod)) return false; - // Static method's parameter types are guaranteed to be `object` by the - // AST-no-annotation check (ParameterTypeResolver falls back to object). - // Return type is the discriminator: type inference may yield `object` - // (any-typed bodies) or `bool` (predicate bodies like `v => v > 10`). - // The first uses Func; the second uses Func - // where the helper variant exists. Phase B: capturing arrows now go - // through emitter.TryEmitArrowAsDelegate, which builds the display - // instance + binds the delegate to its Invoke method. - var ret = staticMethod.ReturnType; - System.Reflection.Emit.MethodBuilder chosenHelper; - Type funcType; - if (ret == ctx.Types.Object) - { - chosenHelper = helperMethod; - funcType = typeof(Func); - } - else if (ret == ctx.Types.Boolean && boolHelper != null) + var sParams = staticMethod.GetParameters(); + bool allParamsObject = true; + foreach (var sp in sParams) + if (sp.ParameterType != ctx.Types.Object) { allParamsObject = false; break; } + + if (allParamsObject) { - chosenHelper = boolHelper; - funcType = typeof(Func); + // Untyped fast path (unchanged). The arrow's static method already has + // `object` parameter slots (unannotated params; ParameterTypeResolver + // falls back to object), so it binds directly to Func. The + // return type is the discriminator: `object` (any-typed body) uses + // Func + helperMethod; `bool` (predicate body like + // `v => v > 10`) uses Func + boolHelper where present. + // Capturing arrows go through emitter.TryEmitArrowAsDelegate, which + // builds the display instance + binds to its Invoke method. + var ret = staticMethod.ReturnType; + System.Reflection.Emit.MethodBuilder chosenHelper; + Type funcType; + if (ret == ctx.Types.Object) + { + chosenHelper = helperMethod; + funcType = typeof(Func); + } + else if (ret == ctx.Types.Boolean && boolHelper != null) + { + chosenHelper = boolHelper; + funcType = typeof(Func); + } + else + { + return false; + } + + if (!emitter.TryEmitArrowAsDelegate(af, funcType)) + return false; + ctx.IL.Emit(OpCodes.Call, chosenHelper); + return true; } - else - { + + // Typed-param adapter path (#861): an annotated callback compiles to a + // typed static method (e.g. double(double)/bool(double)) that cannot bind + // to Func directly. Bind a per-arrow boxed adapter that + // marshals object↔typed around the arrow call, then drive the matching + // Array*Direct helper. + // + // #861 L4: when the predicate arrow returns `bool` and a Func + // helper variant exists (filter/find/findIndex/some/every), emit a + // bool-returning adapter and call that variant — skipping the per-element + // result box + IsTruthy the object helper would otherwise do. + bool wantBool = boolHelper != null && staticMethod.ReturnType == ctx.Types.Boolean; + if (!TryBindTypedArrowAdapter(emitter, af, staticMethod, funcArity: 1, boolReturn: wantBool)) return false; + ctx.IL.Emit(OpCodes.Call, wantBool ? boolHelper! : helperMethod); + return true; + } + + /// + /// Binds a per-arrow boxed adapter (object(object[,object])) for an annotated, + /// non-capturing callback arrow to a Func<object,…> of + /// parameters, leaving the delegate on the stack + /// for the caller to pass to an Array*Direct helper. Returns false (reflective + /// fallback) for capturing arrows or any non-marshallable param/return type. + /// + private static bool TryBindTypedArrowAdapter( + IEmitterContext emitter, + Expr.ArrowFunction af, + System.Reflection.Emit.MethodBuilder arrowMethod, + int funcArity, + bool boolReturn) + { + var ctx = emitter.Context; + + // Marshallable gate (shared): stay in the reflective path's no-arg-conversion regime + // (concrete double/bool/string, or object) so the adapter's unbox/cast matches MethodInvoker + // semantics exactly. Union/nullable params already widen to object in ParameterTypeResolver, so + // a non-object slot here is a concrete value/reference type. + if (!IsAdapterMarshallable(ctx, arrowMethod.ReturnType)) return false; + foreach (var p in arrowMethod.GetParameters()) + if (!IsAdapterMarshallable(ctx, p.ParameterType)) return false; + + // boolReturn (#861 L4) binds Func for a bool-returning predicate so the + // *DirectBool helper consumes an unboxed bool. Only the 1-arg predicate helpers pass it. + Type funcType = boolReturn + ? typeof(Func) + : (funcArity == 2 ? typeof(Func) : typeof(Func)); + var funcCtor = funcType.GetConstructor([typeof(object), typeof(IntPtr)])!; + + if (ctx.DisplayClasses.TryGetValue(af, out var displayClass)) + { + // #861 L3: capturing arrow — arrowMethod is the instance Invoke on the display class. + // Emit an INSTANCE adapter, build the display instance (capturing live locals), then bind + // (displayInstance, ldftn adapter). The display instance is built fresh at the call site, + // exactly as the reflective $TSFunction path does. + var adapter = ctx.ArrowBoxedAdapters.GetOrEmit(displayClass, arrowMethod, funcArity, instance: true, boolReturn: boolReturn); + if (!emitter.TryEmitCapturingArrowDisplayInstance(af)) + return false; // Stack: [displayInstance] + ctx.IL.Emit(OpCodes.Ldftn, adapter); + ctx.IL.Emit(OpCodes.Newobj, funcCtor); + return true; } - if (!emitter.TryEmitArrowAsDelegate(af, funcType)) - return false; - ctx.IL.Emit(OpCodes.Call, chosenHelper); + // #861 L1: non-capturing arrow — arrowMethod is a static method on its declaring $Program type. + // Using DeclaringType (rather than ctx.ProgramType, set only on the top-level context) lets the + // adapter fire inside function/method bodies too. Bind (null, ldftn adapter). + if (arrowMethod.DeclaringType is not TypeBuilder programType) return false; + var staticAdapter = ctx.ArrowBoxedAdapters.GetOrEmit(programType, arrowMethod, funcArity, instance: false, boolReturn: boolReturn); + ctx.IL.Emit(OpCodes.Ldnull); + ctx.IL.Emit(OpCodes.Ldftn, staticAdapter); + ctx.IL.Emit(OpCodes.Newobj, funcCtor); return true; } + private static bool IsAdapterMarshallable(CompilationContext ctx, Type t) + => t == ctx.Types.Object || t == ctx.Types.Double + || t == ctx.Types.Boolean || t == ctx.Types.String; + /// /// Reduce-specific fast path: the callback is binary /// (acc, element) => newAcc, requires Func<object, object, @@ -839,17 +971,32 @@ private static bool TryEmitReduceDirectCall(IEmitterContext emitter, List if (af.Parameters.Count != 2) return false; foreach (var p in af.Parameters) { - if (p.Type != null) return false; + // Annotated params now supported via a boxed adapter (#861). if (p.IsRest || p.IsOptional) return false; if (p.DefaultValue != null) return false; } if (!ctx.ArrowMethods.TryGetValue(af, out var staticMethod)) return false; - if (staticMethod.ReturnType != ctx.Types.Object) return false; - var func3 = typeof(Func); - // Stack: [list]. Build delegate (capturing or non-capturing). - if (!emitter.TryEmitArrowAsDelegate(af, func3)) - return false; + var sParams = staticMethod.GetParameters(); + bool allObject = staticMethod.ReturnType == ctx.Types.Object; + if (allObject) + foreach (var sp in sParams) + if (sp.ParameterType != ctx.Types.Object) { allObject = false; break; } + + // Stack: [list]. Build the (acc, element) => newAcc delegate. + if (allObject) + { + // Untyped fast path (unchanged): object(object,object) binds directly. + if (!emitter.TryEmitArrowAsDelegate(af, typeof(Func))) + return false; + } + else + { + // Annotated reducer (e.g. double(double,double)): bridge via a boxed + // adapter object(object,object). + if (!TryBindTypedArrowAdapter(emitter, af, staticMethod, funcArity: 2, boolReturn: false)) + return false; + } // Push initial value (boxed object). emitter.EmitExpression(arguments[1]); emitter.EmitBoxIfNeeded(arguments[1]); diff --git a/Compilation/Emitters/IEmitterContext.cs b/Compilation/Emitters/IEmitterContext.cs index 7779ca0f..72575a63 100644 --- a/Compilation/Emitters/IEmitterContext.cs +++ b/Compilation/Emitters/IEmitterContext.cs @@ -124,4 +124,13 @@ public interface IEmitterContext /// path when this returns false. /// bool TryEmitArrowAsDelegate(Expr.ArrowFunction af, Type delegateType); + + /// + /// Builds the display-class instance for a capturing arrow (new DisplayClass() + /// + populate captured fields), leaving it on the stack. Used by the array-HOF boxed- + /// adapter fast path (#861 L3) to bind an instance adapter to (displayInstance, ldftn + /// instanceAdapter). Returns false (without emitting) for unsupported emitters or when + /// the arrow has no registered display class. + /// + bool TryEmitCapturingArrowDisplayInstance(Expr.ArrowFunction af); } diff --git a/Compilation/ExpressionEmitterBase.cs b/Compilation/ExpressionEmitterBase.cs index a8daa730..f1c8d4f0 100644 --- a/Compilation/ExpressionEmitterBase.cs +++ b/Compilation/ExpressionEmitterBase.cs @@ -58,6 +58,15 @@ bool IEmitterContext.TryEmitArrowAsDelegate(Expr.ArrowFunction af, Type delegate /// protected virtual bool TryEmitArrowAsDelegate(Expr.ArrowFunction af, Type delegateType) => false; + bool IEmitterContext.TryEmitCapturingArrowDisplayInstance(Expr.ArrowFunction af) + => TryEmitCapturingArrowDisplayInstance(af); + + /// + /// Default: only can build a capturing arrow's display + /// instance (the display machinery lives there). Other contexts decline (#861 L3). + /// + protected virtual bool TryEmitCapturingArrowDisplayInstance(Expr.ArrowFunction af) => false; + #endregion protected readonly StateMachineEmitHelpers _helpers; diff --git a/Compilation/ILEmitter.Calls.Closures.cs b/Compilation/ILEmitter.Calls.Closures.cs index 0755733b..e0dcf76f 100644 --- a/Compilation/ILEmitter.Calls.Closures.cs +++ b/Compilation/ILEmitter.Calls.Closures.cs @@ -52,6 +52,21 @@ protected override bool TryEmitArrowAsDelegate(Expr.ArrowFunction af, Type deleg return true; } + /// + /// #861 L3: builds a capturing arrow's display-class instance on the stack so the array-HOF + /// boxed-adapter path can bind an instance adapter to it. Returns false if the arrow has no + /// registered display class / constructor. + /// + protected override bool TryEmitCapturingArrowDisplayInstance(Expr.ArrowFunction af) + { + if (!_ctx.DisplayClasses.TryGetValue(af, out var displayClass)) + return false; + if (!EmitCapturingArrowDisplayInstance(af, displayClass)) + return false; + SetStackUnknown(); + return true; + } + protected override void EmitArrowFunction(Expr.ArrowFunction af) { // Check if this is an async arrow function with a state machine diff --git a/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs new file mode 100644 index 00000000..59afa1f9 --- /dev/null +++ b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs @@ -0,0 +1,364 @@ +using SharpTS.Tests.Infrastructure; +using Xunit; + +namespace SharpTS.Tests.SharedTests; + +/// +/// Tests for #861: array higher-order-function callbacks (map/filter/reduce/forEach/ +/// find/some/every) whose arrow has ANNOTATED parameters are de-virtualized in compiled +/// mode. An annotated callback like (x: number): number => x*2 compiles to a typed +/// static method (double(double)) that cannot bind to the Func<object,object> +/// the Array*Direct helpers expect; a per-arrow boxed adapter bridges it, so the +/// per-element reflective $TSFunction/MethodInvoker dispatch is removed. +/// +/// These run against BOTH the interpreter and the compiler. The point is interpreter/compiled +/// parity: the adapter's unbox/box must match the reflective path exactly, and the cases that +/// fall back (capturing arrows, multi-arg callbacks) must still produce correct results. A +/// wrong adapter coercion or a mis-fired fast path surfaces here as a compiled-mode mismatch. +/// +public class ArrayHofAnnotatedCallbackTests +{ + // ── Adapter path: annotated callbacks inside a function (the benchmark shape) ── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedMap_Number(ExecutionMode mode) + { + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + return a.map((x: number): number => x * 2).join(","); + } + console.log(f()); + """; + Assert.Equal("2,4,6,8,10\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedFilter_BooleanPredicate(ExecutionMode mode) + { + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5, 6]; + return a.filter((x: number): boolean => x % 2 === 0).join(","); + } + console.log(f()); + """; + Assert.Equal("2,4,6\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedReduce_TwoTypedParams(ExecutionMode mode) + { + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4, 5]; + return a.reduce((acc: number, x: number): number => acc + x, 0); + } + console.log(f()); + """; + Assert.Equal("15\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedChain_MapFilterReduce(ExecutionMode mode) + { + // The array-methods benchmark shape: every callback param is annotated. + var source = """ + function arrayMethodWork(n: number): number { + const arr: number[] = []; + for (let i: number = 0; i < n; i++) { arr.push(i); } + const doubled = arr.map((x: number): number => x * 2); + const evens = doubled.filter((x: number): boolean => x % 4 === 0); + return evens.reduce((acc: number, x: number): number => acc + x, 0); + } + console.log(arrayMethodWork(10)); + """; + Assert.Equal("40\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedPredicates_FindSomeEveryFindIndex(ExecutionMode mode) + { + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + const found = a.find((x: number): boolean => x > 3); + const hasBig = a.some((x: number): boolean => x > 4); + const allPos = a.every((x: number): boolean => x > 0); + const idx = a.findIndex((x: number): boolean => x === 3); + return found + "," + hasBig + "," + allPos + "," + idx; + } + console.log(f()); + """; + Assert.Equal("4,true,true,2\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedForEach_Void(ExecutionMode mode) + { + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4]; + let sum: number = 0; + a.forEach((x: number): void => { sum = sum + x; }); + return sum; + } + console.log(f()); + """; + Assert.Equal("10\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedStringParam_MapsToLength(ExecutionMode mode) + { + var source = """ + function f(): string { + const s: string[] = ["ab", "c", "defg"]; + return s.map((w: string): number => w.length).join(","); + } + console.log(f()); + """; + Assert.Equal("2,1,4\n", TestHarness.Run(source, mode)); + } + + // ── Coercion parity: non-integer / NaN elements must marshal identically ── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedMap_FloatAndNaN(ExecutionMode mode) + { + var source = """ + function f(): string { + const b: number[] = [1.5, NaN, 3]; + return b.map((x: number): number => x + 1).join(","); + } + console.log(f()); + """; + Assert.Equal("2.5,NaN,4\n", TestHarness.Run(source, mode)); + } + + // ── Fallback cases: must NOT take the 1-arg adapter, must stay correct ── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void MultiArgCallback_UsesIndex_FallsBack(ExecutionMode mode) + { + // 2-param map callback uses the index → arity guard keeps it on the reflective + // path; result must still be correct. + var source = """ + function f(): string { + const a: number[] = [10, 20, 30]; + return a.map((x: number, i: number): number => x + i).join(","); + } + console.log(f()); + """; + Assert.Equal("10,21,32\n", TestHarness.Run(source, mode)); + } + + // ── #861 L3: capturing annotated arrows (instance adapter on the display class) ── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingAnnotatedArrow_Map(ExecutionMode mode) + { + // Capturing arrow → instance adapter on the display class (L3); the captured value must + // marshal correctly through the adapter. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3]; + const k: number = 10; + return a.map((x: number): number => x + k).join(","); + } + console.log(f()); + """; + Assert.Equal("11,12,13\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingAnnotatedArrow_Reduce(ExecutionMode mode) + { + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4, 5]; + const base: number = 100; + return a.reduce((acc: number, x: number): number => acc + x + base, 0); + } + console.log(f()); + """; + Assert.Equal("515\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingAnnotatedArrow_PerIterationFreshCapture(ExecutionMode mode) + { + // The display instance must be built FRESH at each call so each closure captures the + // current loop variable, not a shared one. + var source = """ + function f(): string { + const fns: number[] = []; + for (let i: number = 0; i < 3; i++) { + const arr: number[] = [10, 20]; + fns.push(arr.map((x: number): number => x + i)[0]); + } + return fns.join(","); + } + console.log(f()); + """; + Assert.Equal("10,11,12\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingBooleanPredicate_Filter(ExecutionMode mode) + { + // L3 (capturing instance adapter) + L4 (bool-returning adapter → *DirectBool): a captured + // threshold in a typed boolean predicate must still filter correctly. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5, 6]; + const t: number = 3; + return a.filter((x: number): boolean => x > t).join(","); + } + console.log(f()); + """; + Assert.Equal("4,5,6\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void BooleanPredicate_SomeEvery_ShortCircuit(ExecutionMode mode) + { + // L4 bool adapter under the short-circuiting some/every must agree with the reflective path. + var source = """ + function f(): string { + const a: number[] = [2, 4, 6, 7, 8]; + const someOdd = a.some((x: number): boolean => x % 2 === 1); + const allEven = a.every((x: number): boolean => x % 2 === 0); + return someOdd + "," + allEven; + } + console.log(f()); + """; + Assert.Equal("true,false\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingAnnotatedArrow_Chained(ExecutionMode mode) + { + // Capturing arrows across a chained map().filter().reduce() (L1 capturing adapter + L2 round-trip). + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4, 5]; + const m: number = 3; + return a.map((x: number): number => x * m) + .filter((x: number): boolean => x > m) + .reduce((s: number, x: number): number => s + x, 0); + } + console.log(f()); + """; + Assert.Equal("42\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void UntypedCallback_StillWorks(ExecutionMode mode) + { + // Unannotated callback keeps the pre-existing untyped direct/reflective path. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + return a.map(x => x + 1).join(","); + } + console.log(f()); + """; + Assert.Equal("2,3,4,5,6\n", TestHarness.Run(source, mode)); + } + + // ── #861 L2: chained-stage round-trip elimination ── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ChainedMapFilter_AssignedToArrayVariable(ExecutionMode mode) + { + // The final stage's result is consumed as an array value, so its $Array wrap must be + // preserved (only the intermediate map result drops it). `.join` then works on it. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + const r = a.map((x: number): number => x * 2).filter((x: number): boolean => x > 4); + return r.join(","); + } + console.log(f()); + """; + Assert.Equal("6,8,10\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ChainedMapFilter_LengthOnResult(ExecutionMode mode) + { + // .length on the chain result requires the final $Array wrap to survive. + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4, 5]; + return a.map((x: number): number => x * 2).filter((x: number): boolean => x > 5).length; + } + console.log(f()); + """; + Assert.Equal("3\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ChainedMapSlice_MixedCallbackAndPlainArgs(ExecutionMode mode) + { + // Inner map (callback) feeds outer slice (plain args) — the bare List must flow across the + // boundary and slice must consume it correctly. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + return a.map((x: number): number => x * 2).slice(1, 3).join(","); + } + console.log(f()); + """; + Assert.Equal("4,6\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ChainedFilterMap_UntypedAndAnnotatedMixed(ExecutionMode mode) + { + // Inner filter is annotated, outer map is untyped — both stages chain through a bare List. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5, 6]; + return a.filter((x: number): boolean => x % 2 === 0).map(x => x * 10).join(","); + } + console.log(f()); + """; + Assert.Equal("20,40,60\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ConstBoundAnnotatedCallback_Resolves(ExecutionMode mode) + { + // const-bound arrow callback resolves through ConstArrowBindings and takes the + // adapter path when annotated. + var source = """ + const dbl = (x: number): number => x * 2; + const out = [3, 4, 5].map(dbl).join(","); + console.log(out); + """; + Assert.Equal("6,8,10\n", TestHarness.Run(source, mode)); + } +}