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, call s 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));
+ }
+}