diff --git a/src/Fable.AST/Fable.fs b/src/Fable.AST/Fable.fs index abfe1db24..babda01ba 100644 --- a/src/Fable.AST/Fable.fs +++ b/src/Fable.AST/Fable.fs @@ -757,6 +757,9 @@ type Witness = IsInstance: bool FileName: string Expr: Expr + /// The name of the generic parameter this witness satisfies (e.g. "'a" from `'a: (static member M: unit -> string)`). + /// Used to disambiguate when multiple witnesses match the same trait name and arg types. + GenericParamName: string option } member this.ArgTypes = diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 92ffc2a89..bd4979ab0 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli) * [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli) * [JS/TS] Support format specifiers and single hole in JSX string templates (by @MangelMaxime) +* [All] Fix generic parameter resolution in inline functions with static member constraints (by @Programmerino and @MangelMaxime) ## 5.0.0-rc.2 - 2026-03-03 diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index d6f5ce9f3..784594e31 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli) * [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli) * [JS/TS] Support format specifiers and single hole in JSX string templates (by @MangelMaxime) +* [All] Fix generic parameter resolution in inline functions with static member constraints (by @Programmerino and @MangelMaxime) ## 5.0.0-rc.2 - 2026-03-03 diff --git a/src/Fable.Transforms/FSharp2Fable.Util.fs b/src/Fable.Transforms/FSharp2Fable.Util.fs index 3ff82f83c..e0b2eeb62 100644 --- a/src/Fable.Transforms/FSharp2Fable.Util.fs +++ b/src/Fable.Transforms/FSharp2Fable.Util.fs @@ -1728,6 +1728,48 @@ module TypeHelpers = && listEquals (typeEquals false) argTypes w.ArgTypes ) + // Enhanced version that handles multiple witnesses for the same trait + // by using source type information to pick the correct one + let tryFindWitnessWithSourceTypes (ctx: Context) sourceTypes argTypes isInstance traitName = + let matchingWitnesses = + ctx.Witnesses + |> List.filter (fun w -> + w.TraitName = traitName + && w.IsInstance = isInstance + && listEquals (typeEquals false) argTypes w.ArgTypes + ) + + match matchingWitnesses with + | [] -> None + | [ single ] -> Some single + | multiple -> + // Multiple witnesses match by trait name and arg types. + // Use the source type to derive the original generic parameter name, then find + // the witness tagged with that name. + // + // Two cases: + // 1. sourceTypes contains a raw GenericParam (e.g. when compiling an inline function + // whose body is not yet specialized): extract the param name directly. + // 2. sourceTypes contains a resolved DeclaredType (e.g. 'b was resolved to TestTypeB + // via ctx.GenericArgs before being passed here): reverse-lookup in ctx.GenericArgs + // to recover the original param name ("b"). + let genParamName = + match sourceTypes with + | [ Fable.GenericParam(name, _, _) ] -> Some name + | [ resolvedType ] -> + // Reverse-lookup: find the generic param name whose resolved type matches + ctx.GenericArgs + |> Map.tryFindKey (fun _paramName paramType -> typeEquals false paramType resolvedType) + | _ -> None + + multiple + |> List.tryFind (fun w -> + match genParamName, w.GenericParamName with + | Some gpName, Some wGpName -> gpName = wGpName + | _ -> false + ) + |> Option.orElse (List.tryHead multiple) + module Identifiers = open Helpers open TypeHelpers diff --git a/src/Fable.Transforms/FSharp2Fable.fs b/src/Fable.Transforms/FSharp2Fable.fs index 953e3d1f3..72348c8a7 100644 --- a/src/Fable.Transforms/FSharp2Fable.fs +++ b/src/Fable.Transforms/FSharp2Fable.fs @@ -927,9 +927,10 @@ let private transformExpr (com: IFableCompiler) (ctx: Context) appliedGenArgs fs return Fable.Unresolved(e, typ, r) | None -> - match tryFindWitness ctx argTypes flags.IsInstance traitName with + let sourceTypes = List.map (makeType ctx.GenericArgs) sourceTypes + + match tryFindWitnessWithSourceTypes ctx sourceTypes argTypes flags.IsInstance traitName with | None -> - let sourceTypes = List.map (makeType ctx.GenericArgs) sourceTypes return transformTraitCall com ctx r typ sourceTypes traitName flags.IsInstance argTypes argExprs | Some w -> let callInfo = makeCallInfo None argExprs argTypes @@ -980,23 +981,55 @@ let private transformExpr (com: IFableCompiler) (ctx: Context) appliedGenArgs fs match witnesses with | [] -> return ctx | witnesses -> + // Build a map from entity full name -> generic param name. + // Each membGenArg corresponds to a GenericParameter at the same index. + // When a concrete type (e.g. TestTypeA) satisfies a constraint for 'a, + // membGenArgs contains TestTypeA's FSharpType at the position of 'a in + // memb.GenericParameters. This lets us look up the param name from the + // declaring entity of the called member inside the witness body, + // without relying on witness ordering. + let entityFullNameToParamName = + (Map.empty, Seq.zip memb.GenericParameters membGenArgs) + ||> Seq.fold (fun map (gp, fsType) -> + if fsType.HasTypeDefinition then + // Protect against primitive types (e.g. int) throwing when accessing FullName + match fsType.TypeDefinition.TryFullName with + | Some fullName -> Map.add fullName gp.Name map + | None -> map + else + map + ) + let witnesses = witnesses - |> List.choose ( - function - // Index is not reliable, just append witnesses from parent call - | FSharpExprPatterns.WitnessArg _idx -> None + |> List.choose (fun w -> + match w with + // WitnessArg entries are pass-throughs from an outer scope; + // skip them so they are inherited from ctx.Witnesses as-is. + // | FSharpExprPatterns.WitnessArg _idx -> None | NestedLambda(args, body) -> match body with - | FSharpExprPatterns.Call(callee, memb, _, _, _args) -> - Some(memb.CompiledName, Option.isSome callee, args, body) + | FSharpExprPatterns.Call(callee, calledMemb, _, _, _args) -> + let genParamName = + calledMemb.DeclaringEntity + |> Option.bind (fun ent -> + Map.tryFind ent.FullName entityFullNameToParamName + ) + + Some( + calledMemb.CompiledName, + Option.isSome callee, + args, + body, + genParamName + ) | FSharpExprPatterns.AnonRecordGet(_, calleeType, fieldIndex) -> let fieldName = calleeType.AnonRecordTypeDetails.SortedFieldNames[fieldIndex] - Some("get_" + fieldName, true, args, body) + Some("get_" + fieldName, true, args, body, None) | FSharpExprPatterns.FSharpFieldGet(_, _, field) -> - Some("get_" + field.Name, true, args, body) + Some("get_" + field.Name, true, args, body, None) | _ -> None | _ -> None ) @@ -1005,7 +1038,7 @@ let private transformExpr (com: IFableCompiler) (ctx: Context) appliedGenArgs fs // so a witness may need other witnesses to be resolved return! (ctx, List.rev witnesses) - ||> trampolineListFold (fun ctx (traitName, isInstance, args, body) -> + ||> trampolineListFold (fun ctx (traitName, isInstance, args, body, genParamName) -> trampoline { let ctx, args = makeFunctionArgs com ctx args @@ -1017,6 +1050,7 @@ let private transformExpr (com: IFableCompiler) (ctx: Context) appliedGenArgs fs IsInstance = isInstance FileName = com.CurrentFile Expr = Fable.Delegate(args, body, None, Fable.Tags.empty) + GenericParamName = genParamName } return { ctx with Witnesses = w :: ctx.Witnesses } @@ -2460,11 +2494,10 @@ let resolveInlineExpr (com: IFableCompiler) ctx info expr = let argExprs = argExprs |> List.map (resolveInlineExpr com ctx info) - match tryFindWitness ctx argTypes isInstance traitName with - | None -> - let sourceTypes = sourceTypes |> List.map (resolveInlineType ctx.GenericArgs) + let sourceTypes = sourceTypes |> List.map (resolveInlineType ctx.GenericArgs) - transformTraitCall com ctx r t sourceTypes traitName isInstance argTypes argExprs + match tryFindWitnessWithSourceTypes ctx sourceTypes argTypes isInstance traitName with + | None -> transformTraitCall com ctx r t sourceTypes traitName isInstance argTypes argExprs | Some w -> // As witnesses come from the context, idents may be duplicated, see #2855 let info = diff --git a/tests/Js/Main/TypeTests.fs b/tests/Js/Main/TypeTests.fs index 4be38a36b..27c1c98ae 100644 --- a/tests/Js/Main/TypeTests.fs +++ b/tests/Js/Main/TypeTests.fs @@ -639,6 +639,50 @@ type Model = unit let update (model: Model) = model, () +// Test types for generic parameter static member resolution +type TestTypeA = + static member GetValue() = "A" + static member Combine(x: int, y: int) = x + y + 100 + +type TestTypeB = + static member GetValue() = "B" + static member Combine(x: int, y: int) = x + y + 200 + +type TestTypeC = + static member GetValue() = "C" + static member Combine(x: int, y: int) = x + y + 300 + +// Inline functions for testing multiple generic parameters with static member constraints +let inline getTwoValues<'a, 'b when 'a: (static member GetValue: unit -> string) + and 'b: (static member GetValue: unit -> string)> () = + 'a.GetValue(), 'b.GetValue() + +let inline getThreeValues<'a, 'b, 'c when 'a: (static member GetValue: unit -> string) + and 'b: (static member GetValue: unit -> string) + and 'c: (static member GetValue: unit -> string)> () = + 'a.GetValue(), 'b.GetValue(), 'c.GetValue() + +let inline getValuesAndCombine<'a, 'b when 'a: (static member GetValue: unit -> string) + and 'a: (static member Combine: int * int -> int) + and 'b: (static member GetValue: unit -> string) + and 'b: (static member Combine: int * int -> int)> x y = + let aVal = 'a.GetValue() + let bVal = 'b.GetValue() + let aCombined = 'a.Combine(x, y) + let bCombined = 'b.Combine(x, y) + (aVal, aCombined), (bVal, bCombined) + +let inline getReversed<'x, 'y when 'x: (static member GetValue: unit -> string) + and 'y: (static member GetValue: unit -> string)> () = + 'y.GetValue(), 'x.GetValue() + +let inline innerGet<'t when 't: (static member GetValue: unit -> string)> () = + 't.GetValue() + +let inline outerGet<'a, 'b when 'a: (static member GetValue: unit -> string) + and 'b: (static member GetValue: unit -> string)> () = + innerGet<'a>(), innerGet<'b>() + let tests = testList "Types" [ @@ -1394,4 +1438,36 @@ let tests = (upper :> IGenericMangledInterface).ReadOnlyValue |> equal "value" (upper :> IGenericMangledInterface).SetterOnlyValue <- "setter only value" (upper :> IGenericMangledInterface).Value |> equal "setter only value" + + // Test for generic type parameter static member resolution in inline functions + // https://github.com/fable-compiler/Fable/issues/4093 + testCase "Inline function with two generic parameters resolves static members correctly" <| fun () -> + let result = getTwoValues() + result |> equal ("A", "B") + + testCase "Inline function with three generic parameters resolves static members correctly" <| fun () -> + let result = getThreeValues() + result |> equal ("A", "B", "C") + + testCase "Inline function with multiple constraints per type parameter works" <| fun () -> + let result = getValuesAndCombine 10 20 + result |> equal (("A", 130), ("B", 230)) + + testCase "Inline function with reversed type parameter order works" <| fun () -> + let result = getReversed() + result |> equal ("B", "A") + + testCase "Nested inline functions resolve generic parameters correctly" <| fun () -> + let result = outerGet() + result |> equal ("A", "B") + + testCase "Different type parameter combinations work correctly" <| fun () -> + let result1 = getTwoValues() + result1 |> equal ("B", "A") + + let result2 = getTwoValues() + result2 |> equal ("C", "A") + + let result3 = getTwoValues() + result3 |> equal ("B", "C") ]