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
4 changes: 4 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

* [Beam] Support `[<ImportAll>]` + `[<Erase>]` interface pattern for typed FFI bindings (by @dbrattli)

### Fixed

* [TS] Correctly resolve type references for `TypeScriptTaggedUnion` (by @MangelMaxime and @jrwone0)
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 @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

* [Beam] Support `[<ImportAll>]` + `[<Erase>]` interface pattern for typed FFI bindings (by @dbrattli)

### Fixed

* [TS] Correctly resolve type references for `TypeScriptTaggedUnion` (by @MangelMaxime and @jrwone0)
Expand Down
20 changes: 16 additions & 4 deletions src/Fable.Transforms/Beam/FABLE-BEAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ decision trees, and let/letrec bindings all produce correct Erlang output.
| HashSetTests.fs | 20 | HashSet creation, Add, Remove, Contains, Count, Clear, UnionWith, IntersectWith, ExceptWith, iteration, records |
| EnumTests.fs | 20 | Enum HasFlag, comparison, EnumOfValue/ToValue, pattern matching, bitwise ops, inlined EnumOfValue, decision targets |
| UnionTypeTests.fs | 18 | Union construction, matching, structural equality, active patterns |
| InteropTests.fs | 17 | Erlang interop, emitErl, Import attribute, module calls |
| InteropTests.fs | 20 | Erlang interop, emitErl, Import attribute, ImportAll + Erase interface, module calls |
| DictionaryTests.fs | 17 | Dictionary creation, Add, Count, indexer get/set, ContainsKey, ContainsValue, Remove, TryGetValue, Clear, dict function, integer keys, duplicate key throws, missing key throws, iteration, creation from existing dict |
| AsyncTests.fs | 31 | Async return, let!/do!, return!, try-with, sleep, parallel, ignore, start immediate, while/for binding, exception handling, nested try/with, StartWithContinuations, Async.Catch, FromContinuations, deep recursion, nested failure propagation, try/finally, Async.Bind propagation, unit argument erasure, cancellation (CTS create/cancel, register, multiple registers, pre-cancelled token, auto-cancel, Dispose, custom exceptions) |
| QueueTests.fs | 17 | Queue creation, Enqueue, Dequeue, Peek, TryDequeue, TryPeek, Contains, Clear, ToArray, throws |
Expand All @@ -415,7 +415,7 @@ decision trees, and let/letrec bindings all produce correct Erlang output.
| MailboxProcessorTests.fs | 3 | MailboxProcessor post, postAndAsyncReply, postAndAsyncReply with falsy values |
| SudokuTests.fs | 1 | Integration test: Sudoku solver using Seq, Array, ranges |
| ObservableTests.fs | 12 | Observable.subscribe/add/choose/filter/map/merge/pairwise/partition/scan/split, IObservable.Subscribe, Disposing |
| **Total** | **2077** | |
| **Total** | **2080** | |

### Phase 3: Discriminated Unions & Records -- COMPLETE

Expand Down Expand Up @@ -946,6 +946,13 @@ alone eliminates the single hardest piece of the Fable.Python runtime.
Detection: `transformCall`'s `Get(calleeExpr, FieldGet, _, _)` branch checks if
`calleeExpr.Type` is a `DeclaredType` with `entity.IsInterface`. Self-referencing
members (e.g., `x.Print()` inside another member) are not yet supported.
- **ImportAll + Erase interface**: `[<ImportAll("module")>]` + `[<Erase>]` interface pattern
for typed FFI bindings. `myModule.someMethod(args)` → `module:some_method(Args)`.
Detected in both `transformCall` (method calls) and `transformGet` (property access) by
matching `calleeExpr` as `Import` with `Selector = "*"`. Emits direct Erlang remote calls
instead of `fable_utils:iface_get` dispatch. Method names are converted via
`sanitizeErlangName` (camelCase → snake_case). Same pattern as JS/Python but with `:` call
syntax instead of attribute access.

- **Async/Task CE**: CPS (Continuation-Passing Style) implementation. `Async<T>` is a function
`fun(Ctx) -> ok end` with context map `#{on_success, on_error, on_cancel, cancel_token}`.
Expand Down Expand Up @@ -1073,8 +1080,13 @@ shared mutable state — exactly what BEAM is designed to avoid.
`maps:put/3` for update. Structural equality via native `=:=`.
- **OTP project structure**: Generate a full OTP application structure with
`rebar3`? Or just standalone `.erl` files initially?
- **Interop**: How should F# code call existing Erlang/Elixir libraries?
Fable.Core attributes like `[<Import("lists", "map")>]`?
- ~~**Interop**: How should F# code call existing Erlang/Elixir libraries?
Fable.Core attributes like `[<Import("lists", "map")>]`?~~
**Decided**: Three interop mechanisms: (1) `[<Import("func", "module")>]` for individual
function imports → `module:func(Args)`, (2) `[<Emit("erlang:expr($0)")>]` for inline
Erlang expressions, (3) `[<ImportAll("module")>]` + `[<Erase>]` interface for typed
module bindings → `module:method(Args)`. The ImportAll pattern mirrors JS/Python but
emits Erlang remote calls instead of attribute access.
- ~~**Testing**: Use EUnit, Common Test, or just assert in generated code?~~
**Decided**: xUnit with `[<Fact>]` on .NET side, `Fable.Core.Testing.Assert`
when compiled to BEAM. Same pattern as Python/Rust targets.
Expand Down
230 changes: 127 additions & 103 deletions src/Fable.Transforms/Beam/Fable2Beam.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1805,38 +1805,50 @@ and transformGet (com: IBeamCompiler) (ctx: Context) (kind: GetKind) (typ: Type)
else
erlExpr
| FieldGet info ->
match expr.Type with
| Fable.AST.Fable.Type.DeclaredType(entityRef, _) when isClassType com entityRef ->
let fieldName = sanitizeErlangName info.Name
// ImportAll + Erase interface pattern (property access): os.name → os:name()
match expr with
| Import(importInfo, _, _) when importInfo.Selector = "*" ->
let moduleName = resolveImportModuleName com importInfo.Path
let funcName = sanitizeErlangName info.Name
Beam.ErlExpr.Call(moduleName, funcName, [])
| _ ->

// During constructor, field values may reference other fields via this.FieldName.
// Since put(Ref, #{...}) hasn't happened yet, we use the precomputed Erlang expressions.
let isThisRef =
match ctx.ThisArgVar, erlExpr with
| Some thisVar, Beam.ErlExpr.Variable v -> v = thisVar
| _ -> false
match expr.Type with
| Fable.AST.Fable.Type.DeclaredType(entityRef, _) when isClassType com entityRef ->
let fieldName = sanitizeErlangName info.Name

// During constructor, field values may reference other fields via this.FieldName.
// Since put(Ref, #{...}) hasn't happened yet, we use the precomputed Erlang expressions.
let isThisRef =
match ctx.ThisArgVar, erlExpr with
| Some thisVar, Beam.ErlExpr.Variable v -> v = thisVar
| _ -> false

match isThisRef, ctx.CtorFieldExprs.TryFind(info.Name) with
| true, Some cachedExpr -> cachedExpr
| _ ->
// Class instance: state is stored in process dict, ref is the key
// Use f$ prefix to avoid collision with interface method keys
let classFieldAtom =
Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom("field_" + fieldName)))
match isThisRef, ctx.CtorFieldExprs.TryFind(info.Name) with
| true, Some cachedExpr -> cachedExpr
| _ ->
// Class instance: state is stored in process dict, ref is the key
// Use f$ prefix to avoid collision with interface method keys
let classFieldAtom =
Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom("field_" + fieldName)))

Beam.ErlExpr.Call(Some "maps", "get", [ classFieldAtom; Beam.ErlExpr.Call(None, "get", [ erlExpr ]) ])
| Fable.AST.Fable.Type.DeclaredType(entityRef, _) when isInterfaceType com entityRef ->
let fieldName = sanitizeErlangName info.Name
let fieldAtom = Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom fieldName))
// Interface dispatch: works for both object expressions (maps) and class instances (refs).
// Class interface property getters are stored as 0-arity thunks — iface_get calls them.
// ObjectExpr property getters are stored as plain values — iface_get returns them as-is.
Beam.ErlExpr.Call(Some "fable_utils", "iface_get", [ fieldAtom; erlExpr ])
| _ ->
// Record/union/anonymous record: direct map access, use sanitizeFieldName for disambiguation
let fieldName = sanitizeFieldName info.Name
let fieldAtom = Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom fieldName))
Beam.ErlExpr.Call(Some "maps", "get", [ fieldAtom; erlExpr ])
Beam.ErlExpr.Call(
Some "maps",
"get",
[ classFieldAtom; Beam.ErlExpr.Call(None, "get", [ erlExpr ]) ]
)
| Fable.AST.Fable.Type.DeclaredType(entityRef, _) when isInterfaceType com entityRef ->
let fieldName = sanitizeErlangName info.Name
let fieldAtom = Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom fieldName))
// Interface dispatch: works for both object expressions (maps) and class instances (refs).
// Class interface property getters are stored as 0-arity thunks — iface_get calls them.
// ObjectExpr property getters are stored as plain values — iface_get returns them as-is.
Beam.ErlExpr.Call(Some "fable_utils", "iface_get", [ fieldAtom; erlExpr ])
| _ ->
// Record/union/anonymous record: direct map access, use sanitizeFieldName for disambiguation
let fieldName = sanitizeFieldName info.Name
let fieldAtom = Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom fieldName))
Beam.ErlExpr.Call(Some "maps", "get", [ fieldAtom; erlExpr ])
| ExprGet indexExpr ->
let erlIndex = transformExpr com ctx indexExpr

Expand Down Expand Up @@ -2376,87 +2388,99 @@ and transformCall (com: IBeamCompiler) (ctx: Context) (callee: Expr) (info: Call
)
| _ -> false

let erlCallee = transformExpr com ctx calleeExpr
let args = info.Args |> List.map (transformExpr com ctx)
let calleeHoisted, cleanCallee = extractBlock erlCallee
let argsHoisted, cleanArgs = hoistBlocksFromArgs args
let allHoisted = calleeHoisted @ argsHoisted

if isInterfaceExpr then
// Interface method dispatch: (fable_utils:iface_get(method_name, Obj))(Args)
// Works for both object expressions (maps) and class instances (refs)
let methodAtom = atomLit (sanitizeErlangName fieldInfo.Name)
// ImportAll + Erase interface pattern: factorActor.spawnActor(f) → factor_actor:spawn_actor(F)
// When the callee is an ImportAll (Selector = "*"), emit a direct remote call
// instead of going through fable_utils:iface_get runtime dispatch.
match calleeExpr with
| Import(importInfo, _, _) when importInfo.Selector = "*" ->
let moduleName = resolveImportModuleName com importInfo.Path
let funcName = sanitizeErlangName fieldInfo.Name
let args = info.Args |> List.map (transformExpr com ctx)
let hoisted, cleanArgs = hoistBlocksFromArgs args
Beam.ErlExpr.Call(moduleName, funcName, cleanArgs) |> wrapWithHoisted hoisted
| _ ->

let lookup =
Beam.ErlExpr.Call(Some "fable_utils", "iface_get", [ methodAtom; cleanCallee ])
let erlCallee = transformExpr com ctx calleeExpr
let args = info.Args |> List.map (transformExpr com ctx)
let calleeHoisted, cleanCallee = extractBlock erlCallee
let argsHoisted, cleanArgs = hoistBlocksFromArgs args
let allHoisted = calleeHoisted @ argsHoisted

Beam.ErlExpr.Apply(lookup, cleanArgs) |> wrapWithHoisted allHoisted
else
match fieldInfo.Name with
| "indexOf" ->
// str.indexOf(sub) → case binary:match(Str, Sub) of {Pos,_} -> Pos; nomatch -> -1 end
match cleanArgs with
| [ sub ] ->
let ctr = com.IncrementCounter()
let posVar = $"Idx_pos_%d{ctr}"
if isInterfaceExpr then
// Interface method dispatch: (fable_utils:iface_get(method_name, Obj))(Args)
// Works for both object expressions (maps) and class instances (refs)
let methodAtom = atomLit (sanitizeErlangName fieldInfo.Name)

Beam.ErlExpr.Case(
Beam.ErlExpr.Call(Some "binary", "match", [ cleanCallee; sub ]),
[
{
Pattern = Beam.PTuple [ Beam.PVar posVar; Beam.PWildcard ]
Guard = []
Body = [ Beam.ErlExpr.Variable posVar ]
}
{
Pattern = Beam.PLiteral(Beam.ErlLiteral.AtomLit(Beam.Atom "nomatch"))
Guard = []
Body = [ Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer -1L) ]
}
]
)
|> wrapWithHoisted allHoisted
| _ ->
Beam.ErlExpr.Call(None, "unknown_call", cleanCallee :: cleanArgs)
|> wrapWithHoisted allHoisted
| methodName ->
// Check if this is a constructor-parameter field invoke in a class method body.
// When a class ctor param like `add: int -> int -> int` is used in a method body
// as `add x y`, the Fable AST produces Call(Get(this, FieldGet("add")), {Args=[x,y]}).
// We need to generate (maps:get(field_add, get(This)))(X, Y) instead of add(This, X, Y).
let isCtorFieldInvoke =
ctx.ThisArgVar.IsSome && ctx.CtorParamNames.Contains(methodName)

// Check if callee is a record/anonymous record (function-valued field)
let isRecordLike =
match calleeExpr.Type with
| Fable.AST.Fable.Type.AnonymousRecordType _ -> true
| Fable.AST.Fable.Type.DeclaredType(entityRef, _) ->
match com.TryGetEntity(entityRef) with
| Some entity -> entity.IsFSharpRecord
| None -> false
| _ -> false
let lookup =
Beam.ErlExpr.Call(Some "fable_utils", "iface_get", [ methodAtom; cleanCallee ])

if isCtorFieldInvoke then
// Constructor param field invoke: (maps:get(field_<name>, get(This)))(Args)
let fieldAtom = atomLit ("field_" + sanitizeErlangName methodName)
Beam.ErlExpr.Apply(lookup, cleanArgs) |> wrapWithHoisted allHoisted
else
match fieldInfo.Name with
| "indexOf" ->
// str.indexOf(sub) → case binary:match(Str, Sub) of {Pos,_} -> Pos; nomatch -> -1 end
match cleanArgs with
| [ sub ] ->
let ctr = com.IncrementCounter()
let posVar = $"Idx_pos_%d{ctr}"

let lookup =
Beam.ErlExpr.Call(
Some "maps",
"get",
[ fieldAtom; Beam.ErlExpr.Call(None, "get", [ cleanCallee ]) ]
Beam.ErlExpr.Case(
Beam.ErlExpr.Call(Some "binary", "match", [ cleanCallee; sub ]),
[
{
Pattern = Beam.PTuple [ Beam.PVar posVar; Beam.PWildcard ]
Guard = []
Body = [ Beam.ErlExpr.Variable posVar ]
}
{
Pattern = Beam.PLiteral(Beam.ErlLiteral.AtomLit(Beam.Atom "nomatch"))
Guard = []
Body = [ Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer -1L) ]
}
]
)
|> wrapWithHoisted allHoisted
| _ ->
Beam.ErlExpr.Call(None, "unknown_call", cleanCallee :: cleanArgs)
|> wrapWithHoisted allHoisted
| methodName ->
// Check if this is a constructor-parameter field invoke in a class method body.
// When a class ctor param like `add: int -> int -> int` is used in a method body
// as `add x y`, the Fable AST produces Call(Get(this, FieldGet("add")), {Args=[x,y]}).
// We need to generate (maps:get(field_add, get(This)))(X, Y) instead of add(This, X, Y).
let isCtorFieldInvoke =
ctx.ThisArgVar.IsSome && ctx.CtorParamNames.Contains(methodName)

// Check if callee is a record/anonymous record (function-valued field)
let isRecordLike =
match calleeExpr.Type with
| Fable.AST.Fable.Type.AnonymousRecordType _ -> true
| Fable.AST.Fable.Type.DeclaredType(entityRef, _) ->
match com.TryGetEntity(entityRef) with
| Some entity -> entity.IsFSharpRecord
| None -> false
| _ -> false

Beam.ErlExpr.Apply(lookup, cleanArgs) |> wrapWithHoisted allHoisted
elif isRecordLike then
// Function-valued record field: (maps:get(field, Record))(Args)
let fieldAtom = atomLit (sanitizeFieldName methodName)
let lookup = Beam.ErlExpr.Call(Some "maps", "get", [ fieldAtom; cleanCallee ])
Beam.ErlExpr.Apply(lookup, cleanArgs) |> wrapWithHoisted allHoisted
else
Beam.ErlExpr.Call(None, sanitizeErlangName methodName, cleanCallee :: cleanArgs)
|> wrapWithHoisted allHoisted
if isCtorFieldInvoke then
// Constructor param field invoke: (maps:get(field_<name>, get(This)))(Args)
let fieldAtom = atomLit ("field_" + sanitizeErlangName methodName)

let lookup =
Beam.ErlExpr.Call(
Some "maps",
"get",
[ fieldAtom; Beam.ErlExpr.Call(None, "get", [ cleanCallee ]) ]
)

Beam.ErlExpr.Apply(lookup, cleanArgs) |> wrapWithHoisted allHoisted
elif isRecordLike then
// Function-valued record field: (maps:get(field, Record))(Args)
let fieldAtom = atomLit (sanitizeFieldName methodName)
let lookup = Beam.ErlExpr.Call(Some "maps", "get", [ fieldAtom; cleanCallee ])
Beam.ErlExpr.Apply(lookup, cleanArgs) |> wrapWithHoisted allHoisted
else
Beam.ErlExpr.Call(None, sanitizeErlangName methodName, cleanCallee :: cleanArgs)
|> wrapWithHoisted allHoisted

| _ ->
// Generic callee expression (e.g., operator value like (+) passed as function arg).
Expand Down
Loading
Loading