Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Fable.AST/Fable.fs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ type Ident =
IsThisArgument: bool
IsCompilerGenerated: bool
Range: SourceLocation option
IsInlineIfLambda: bool
}

member x.DisplayName =
Expand Down
4 changes: 4 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* [Beam] Fix non-ASCII characters in string literals being truncated to single bytes — emit `<<"..."/utf8>>` instead of `<<"...">>` (by @dbrattli)
* [Beam] Fix `Emit` expressions with `case` leaking variables into surrounding scope — auto-wrap in IIFE for scope isolation (by @dbrattli)

### Added

[All] Add support for `[<InlineIfLambda>]` (by @MangelMaxime)

## 5.0.0-rc.3 - 2026-03-10

### Added
Expand Down
4 changes: 4 additions & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* [Beam] Fix non-ASCII characters in string literals being truncated to single bytes — emit `<<"..."/utf8>>` instead of `<<"...">>` (by @dbrattli)
* [Beam] Fix `Emit` expressions with `case` leaking variables into surrounding scope — auto-wrap in IIFE for scope isolation (by @dbrattli)

### Added

[All] Add support for `[<InlineIfLambda>]` (by @MangelMaxime)

## 5.0.0-rc.10 - 2026-03-10

### Added
Expand Down
3 changes: 3 additions & 0 deletions src/Fable.Transforms/FSharp2Fable.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1827,6 +1827,9 @@ module Identifiers =
IsCompilerGenerated = fsRef.IsCompilerGenerated
IsMutable = isMutable
Range = Some r
IsInlineIfLambda =
fsRef.Attributes
|> Seq.exists (fun attr -> attr.AttributeType.FullName = Atts.inlineIfLambda)
}

let putIdentInScope com ctx (fsRef: FSharpMemberOrFunctionOrValue) value : Context * Fable.Ident =
Expand Down
140 changes: 135 additions & 5 deletions src/Fable.Transforms/FSharp2Fable.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2615,9 +2615,9 @@ type FableCompiler(com: Compiler) =
ResolvedIdents = Dictionary()
}

let ctx, bindings =
((ctx, []), foldArgs [] (inExpr.Args, args))
||> List.fold (fun (ctx, bindings) (argId, arg) ->
let ctx, bindings, forceInlineMap =
(((ctx, [], Map.empty)), foldArgs [] (inExpr.Args, args))
||> List.fold (fun (ctx, bindings, forceInlineMap) (argId, arg) ->
let argId = resolveInlineIdent ctx info argId
// Change type and mark argId as compiler-generated so Fable also
// tries to inline it in DEBUG mode (some patterns depend on this)
Expand All @@ -2627,9 +2627,101 @@ type FableCompiler(com: Compiler) =
IsCompilerGenerated = true
}

// If this parameter has [<InlineIfLambda>] and the argument is a lambda/delegate,
// force-inline it directly into the body (duplicate at each call site) rather
// than creating a Let binding that beta-reduction may refuse to inline.
let isInlineIfLambda = argId.IsInlineIfLambda

// Helper: try to find a non-inline module-level declaration by its Fable name and
// reconstruct it as a Lambda/Delegate by binding its FSC args and transforming its body.
let tryLambdaFromDeclarations (name: string) =
let decls = com.GetImplementationFile(com.CurrentFile)

let rec tryFindInDecls (decls: FSharpImplementationFileDeclaration list) =
decls
|> List.tryPick (fun decl ->
match decl with
| FSharpImplementationFileDeclaration.Entity(_, subDecls) -> tryFindInDecls subDecls
| FSharpImplementationFileDeclaration.MemberOrFunctionOrValue(memb, membArgIds, fsBody) when
not (isInline memb)
->
let declName, _ = getMemberDeclarationName (this :> Compiler) memb

if declName = name then
let flatArgs = List.concat membArgIds

match flatArgs with
| [] ->
// No args: transform body directly (may already be Lambda/Delegate)
let fableBody = (this :> IFableCompiler).Transform(ctx, fsBody)

match fableBody with
| Fable.Lambda _
| Fable.Delegate _ -> Some fableBody
| _ -> None
| _ ->
// Has args: bind them and wrap transformed body in a lambda
let bodyCtx, fableArgs = bindMemberArgs this ctx membArgIds
let fableBody = (this :> IFableCompiler).Transform(bodyCtx, fsBody)

match fableArgs with
| [ singleArg ] -> Some(Fable.Lambda(singleArg, fableBody, None))
| multiArgs ->
Some(Fable.Delegate(multiArgs, fableBody, None, Fable.Tags.empty))
else
None
| _ -> None
)

tryFindInDecls decls

let effectiveArg =
match arg with
| Fable.IdentExpr identRef ->
// First try local scope (covers anonymous lambda pre-bound by FSC, Case 1)
let fromScope =
ctx.Scope
|> List.tryPick (fun (_, scopeIdent, scopeValue) ->
if scopeIdent.Name = identRef.Name then
scopeValue
else
None
)

match fromScope with
| Some _ -> fromScope |> Option.defaultValue arg
| None ->
// Second, try module-level declarations (IdentExpr case when scope lookup fails)
if isInlineIfLambda then
tryLambdaFromDeclarations identRef.Name |> Option.defaultValue arg
else
arg
| Fable.Lambda(lambdaArg, Fable.Call(Fable.IdentExpr calleeIdent, callInfo, _, _), _) when
isInlineIfLambda
&& callInfo.Args.Length = 1
&& (
match callInfo.Args.[0] with
| Fable.IdentExpr a -> a.Name = lambdaArg.Name
| _ -> false
)
->
// FSC may eta-expand a named function: `myAction` → `fun x -> myAction(x)`.
// Resolve the callee's body from declarations so we inline the real body.
tryLambdaFromDeclarations calleeIdent.Name |> Option.defaultValue arg
| _ -> arg

let ctx = { ctx with Scope = (None, argId, Some arg) :: ctx.Scope }

ctx, (argId, arg) :: bindings
let isLambdaOrDelegate =
match effectiveArg with
| Fable.Lambda _
| Fable.Delegate _ -> true
| _ -> false

if isInlineIfLambda && isLambdaOrDelegate then
(ctx, bindings, Map.add argId.Name effectiveArg forceInlineMap)
else
(ctx, (argId, arg) :: bindings, forceInlineMap)
)

let ctx =
Expand All @@ -2643,6 +2735,21 @@ type FableCompiler(com: Compiler) =

let resolved = resolveInlineExpr this ctx info inExpr.Body

// Substitute [<InlineIfLambda>] lambdas directly into the body at each use site
let resolved =
if Map.isEmpty forceInlineMap then
resolved
else
resolved
|> visitFromInsideOut (
function
| Fable.IdentExpr id as e ->
match Map.tryFind id.Name forceInlineMap with
| Some replacement -> replacement
| None -> e
| e -> e
)

// Some patterns depend on inlined arguments being captured by "magic" Fable.Core functions like
// importValueDynamic. If the value can have side effects, it won't be removed by beta binding
// reduction, so we try to eliminate it here.
Expand Down Expand Up @@ -2747,11 +2854,34 @@ let getInlineExprs fileName (declarations: FSharpImplementationFileDeclaration l
ctx, ident :: idents
)

// CurriedParameterGroups is the authoritative source for parameter-level
// attributes like [<InlineIfLambda>]. The argIds (FSharpMemberOrFunctionOrValue)
// are body-level variables and may not carry parameter attributes on fsRef.Attributes.
// So we zip the built idents with the flattened CurriedParameterGroups and annotate.
// NOTE: for instance members the first ident is the `this` argument, which is NOT
// included in CurriedParameterGroups — skip it when indexing into flatParams.
let flatParams = memb.CurriedParameterGroups |> Seq.collect id |> Seq.toList

let thisOffset =
if memb.IsInstanceMember then
1
else
0

let args =
List.rev idents
|> List.mapi (fun i ident ->
match List.tryItem (i - thisOffset) flatParams with
| Some param when i >= thisOffset && hasAttrib Atts.inlineIfLambda param.Attributes ->
{ ident with IsInlineIfLambda = true }
| _ -> ident
)

// It looks as we don't need memb.DeclaringEntity.GenericParameters here
let genArgs = memb.GenericParameters |> Seq.mapToList (genParamName)

{
Args = List.rev idents
Args = args
Body = com.Transform(ctx, body)
FileName = fileName
GenericArgs = genArgs
Expand Down
1 change: 1 addition & 0 deletions src/Fable.Transforms/Python/Fable2Python.Transforms.fs
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ let transformObjectExpr
IsThisArgument = false
IsCompilerGenerated = true
Range = r
IsInlineIfLambda = false
}
| e -> e
)
Expand Down
4 changes: 4 additions & 0 deletions src/Fable.Transforms/Transforms.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ module Atts =
[<Literal>]
let entryPoint = "Microsoft.FSharp.Core.EntryPointAttribute" // typeof<Microsoft.FSharp.Core.EntryPointAttribute>.FullName

[<Literal>]
let inlineIfLambda = "Microsoft.FSharp.Core.InlineIfLambdaAttribute" // typeof<Microsoft.FSharp.Core.InlineIfLambdaAttribute>.FullName

[<Literal>]
let sealed_ = "Microsoft.FSharp.Core.SealedAttribute" // typeof<Microsoft.FSharp.Core.SealedAttribute>.FullName

Expand Down Expand Up @@ -968,6 +971,7 @@ module AST =
IsThisArgument = false
IsMutable = false
Range = None
IsInlineIfLambda = false
}

/// ATTENTION: Make sure the ident name is unique
Expand Down
4 changes: 3 additions & 1 deletion tests/Dart/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import './src/DictionaryTests.dart' as dictionary;
import './src/EnumTests.dart' as enum_;
import './src/EnumerableTests.dart' as enumerable;
import './src/HashSetTests.dart' as hash_set;
import './src/InlineIfLambdaTests.dart' as inline_if_lambda;
import './src/ListTests.dart' as list;
import './src/MapTests.dart' as map;
import './src/MiscTests.dart' as misc;
Expand Down Expand Up @@ -37,6 +38,7 @@ void main() {
enum_.tests();
enumerable.tests();
hash_set.tests();
inline_if_lambda.tests();
list.tests();
map.tests();
misc.tests();
Expand All @@ -56,4 +58,4 @@ void main() {
tuple.tests();
type_.tests();
union.tests();
}
}
1 change: 1 addition & 0 deletions tests/Dart/src/Fable.Tests.Dart.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<Compile Include="TupleTests.fs" />
<Compile Include="TypeTests.fs" />
<Compile Include="UnionTests.fs" />
<Compile Include="InlineIfLambdaTests.fs" />
<Content Include="..\main.dart" />
</ItemGroup>
</Project>
94 changes: 94 additions & 0 deletions tests/Dart/src/InlineIfLambdaTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
module Fable.Tests.Dart.InlineIfLambda

open Util

// ── helpers ────────────────────────────────────────────────────────────────────

let inline iterateTwice ([<InlineIfLambda>] action) (array: 'T[]) =
for i = 0 to array.Length - 1 do
action array[i]
for i = 0 to array.Length - 1 do
action array[i]

let inline fold ([<InlineIfLambda>] folder: 'State -> 'T -> 'State) (state: 'State) (array: 'T[]) =
let mutable acc = state
for i = 0 to array.Length - 1 do
acc <- folder acc array[i]
acc

let inline applyToFirst ([<InlineIfLambda>] f: 'T -> 'U) (x: 'T) = f x

let inline applyTwice ([<InlineIfLambda>] f: 'T -> 'T) (x: 'T) =
f (f x)

let inline applyPairTwice ([<InlineIfLambda>] f: int -> int -> int) a b =
let first = f a b
f first b

type TwiceBuilder() =
member inline _.Return(x) = x
member inline _.Delay([<InlineIfLambda>] f: unit -> 'T) =
let _ = f ()
f ()
member inline _.Run(x) = x

let twice = TwiceBuilder()

// ── tests ──────────────────────────────────────────────────────────────────────

let tests () =
testCase "Anonymous lambda is inlined across multiple uses" <| fun () ->
let arr = [| 1..10 |]
let mutable sum = 0
arr |> iterateTwice (fun x -> sum <- sum + x)
sum |> equal 110

testCase "Named local lambda is inlined" <| fun () ->
let arr = [| 1..10 |]
let mutable sum = 0
let myAction = fun x -> sum <- sum + x
arr |> iterateTwice myAction
sum |> equal 110

testCase "InlineIfLambda works with fold-style accumulator" <| fun () ->
let arr = [| 1..5 |]
let sum = fold (fun acc x -> acc + x) 0 arr
sum |> equal 15

testCase "InlineIfLambda single application works" <| fun () ->
let result = applyToFirst (fun x -> x * 2) 21
result |> equal 42

testCase "InlineIfLambda applied twice works" <| fun () ->
let result = applyTwice (fun x -> x + 3) 1
result |> equal 7

testCase "InlineIfLambda with two parameters works" <| fun () ->
let result =
applyPairTwice
(fun a b -> a + b)
10
32
result |> equal 74

testCase "InlineIfLambda captures mutable correctly" <| fun () ->
let mutable count = 0
let arr = [| 1..5 |]
arr |> iterateTwice (fun _ -> count <- count + 1)
count |> equal 10

testCase "InlineIfLambda with branching" <| fun () ->
let arr = [| 1..10 |]
let mutable evens = 0
arr |> iterateTwice (fun x -> if x % 2 = 0 then evens <- evens + 1)
evens |> equal 10

testCase "InlineIfLambda on CE builder Delay inlines body at each call site" <| fun () ->
let mutable callCount = 0
let result =
twice {
callCount <- callCount + 1
return callCount
}
callCount |> equal 2
result |> equal 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module AnonymousLambda

let inline iterateTwice ([<InlineIfLambda>] action) (array: 'T[]) =
for i = 0 to array.Length-1 do
action array[i]
for i = 0 to array.Length-1 do
action array[i]

let arr = [| 1.. 100 |]
let mutable sum = 0
arr |> iterateTwice (fun x ->
sum <- sum + x
)
Loading
Loading