Skip to content

Commit e9bae17

Browse files
authored
[Beam] Improve test coverage and fix runtime bugs (2022→2086) (#4347)
1 parent fa85ad8 commit e9bae17

30 files changed

Lines changed: 1375 additions & 241 deletions

src/Fable.Cli/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
* [Beam] Add Erlang/BEAM target (`--lang beam`). Compiles F# to `.erl` source files. 2010 tests passing. (by @dbrattli)
12+
* [Beam] Add Erlang/BEAM target (`--lang beam`). Compiles F# to `.erl` source files. 2086 tests passing. (by @dbrattli)
1313

1414
## 5.0.0-alpha.24 - 2026-02-13
1515

src/Fable.Compiler/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
* [Beam] Add Erlang/BEAM compilation support: Fable2Beam transform, Beam Replacements, ErlangPrinter, and 31 runtime `.erl` modules (by @dbrattli)
12+
* [Beam] Add Erlang/BEAM compilation support: Fable2Beam transform, Beam Replacements, ErlangPrinter, and 31 runtime `.erl` modules. 2086 tests passing. (by @dbrattli)
1313

1414
## 5.0.0-alpha.23 - 2026-02-13
1515

src/Fable.Transforms/Beam/FABLE-BEAM.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Erlang modules implementing F# core types:
118118
| fable_comparison.erl | Comparison | compare/2 returning -1/0/1 | Done |
119119
| fable_char.erl | Char utilities | is_letter/digit/upper/lower/whitespace | Done |
120120
| fable_convert.erl | Type conversions | Robust to_float handling edge cases | Done |
121-
| fable_reflection.erl | Reflection helpers | Type info as Erlang maps | Done |
121+
| fable_reflection.erl | Reflection | Full FSharpType/FSharpValue support: TypeInfo as maps, record/union/tuple/function type tests, GetRecordFields/MakeRecord, GetUnionFields/MakeUnion, GetTupleFields/MakeTuple, PropertyInfo.GetValue | Done |
122122
| fable_result.erl | Result | {ok, V} or {error, E} | Done |
123123
| fable_set.erl | FSharpSet | ordsets (sorted lists), fold/map/filter/partition/union_many/intersect_many | Done |
124124
| fable_async_builder.erl | AsyncBuilder | CPS builder operations (bind, return, delay, etc.) | Done |
@@ -342,7 +342,7 @@ decision trees, and let/letrec bindings all produce correct Erlang output.
342342
2. Compiles tests to `.erl` via Fable (library files auto-copied to `fable_modules/fable-library-beam/`)
343343
3. Compiles library `.erl` files in `fable_modules/fable-library-beam/` with `erlc`
344344
4. Compiles test `.erl` files with `erlc -pa fable_modules/fable-library-beam`
345-
5. Runs an Erlang test runner (`erl_test_runner.erl`) with `-pa fable_modules/fable-library-beam` that discovers and executes all `test_`-prefixed functions (2022 Erlang tests pass)
345+
5. Runs an Erlang test runner (`erl_test_runner.erl`) with `-pa fable_modules/fable-library-beam` that discovers and executes all `test_`-prefixed functions (2077 Erlang tests pass)
346346

347347
| Test File | Tests | Coverage |
348348
| --- | --- | --- |
@@ -375,12 +375,12 @@ decision trees, and let/letrec bindings all produce correct Erlang output.
375375
| UnionTypeTests.fs | 18 | Union construction, matching, structural equality, active patterns |
376376
| InteropTests.fs | 17 | Erlang interop, emitErl, Import attribute, module calls |
377377
| 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 |
378-
| AsyncTests.fs | 17 | Async return, let!/do!, return!, try-with, sleep, parallel, ignore, start immediate, cancellation |
378+
| 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) |
379379
| QueueTests.fs | 17 | Queue creation, Enqueue, Dequeue, Peek, TryDequeue, TryPeek, Contains, Clear, ToArray, throws |
380380
| TailCallTests.fs | 15 | Tail call optimization, recursive functions, mutual recursion (parseTokens/parseNum) |
381381
| TupleTests.fs | 15 | Tuple creation, destructuring, fst/snd, equality, nesting, struct tuples, map, comparison, Item1/Item2 |
382382
| LoopTests.fs | 12 | for loops, while loops, nested loops, mutable variables, for-in over list/array |
383-
| ReflectionTests.fs | 11 | Type info, FSharpType reflection |
383+
| ReflectionTests.fs | 35 | Type info, typedefof, GetGenericTypeDefinition, Type.Name/FullName/Namespace, FSharpType (IsTuple, IsRecord, IsUnion, IsFunction, GetTupleElements, GetFunctionElements, MakeTupleType, GetRecordFields, GetUnionCases), FSharpValue (GetRecordFields, MakeRecord, GetTupleFields, MakeTuple, GetTupleField, GetUnionFields, MakeUnion), PropertyInfo.GetValue, Result/Choice reflection, units of measure type info |
384384
| GuidTests.fs | 10 | Guid.NewGuid, Parse, ToString, Empty, equality, comparison |
385385
| ExceptionTests.fs | 10 | Custom exceptions, type discrimination, nested catch, field access, Message property |
386386
| StackTests.fs | 9 | Stack creation, Push, Pop, Peek, TryPop, TryPeek, Contains, ToArray, Clear |
@@ -395,7 +395,7 @@ decision trees, and let/letrec bindings all produce correct Erlang output.
395395
| MailboxProcessorTests.fs | 3 | MailboxProcessor post, postAndAsyncReply, postAndAsyncReply with falsy values |
396396
| SudokuTests.fs | 1 | Integration test: Sudoku solver using Seq, Array, ranges |
397397
| ObservableTests.fs | 12 | Observable.subscribe/add/choose/filter/map/merge/pairwise/partition/scan/split, IObservable.Subscribe, Disposing |
398-
| **Total** | **2022** | |
398+
| **Total** | **2077** | |
399399

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

@@ -582,7 +582,7 @@ for mutable state, `fable_async:from_continuations` for the receive/reply coordi
582582
### Phase 10: Ecosystem
583583

584584
- [ ] Build integration (`rebar3` or `mix` project generation)
585-
- [x] Test suite (`tests/Beam/`2022 Erlang tests passing, `./build.sh test beam`)
585+
- [x] Test suite (`tests/Beam/`2077 Erlang tests passing, `./build.sh test beam`)
586586
- [x] Erlang test runner (`tests/Beam/erl_test_runner.erl` — discovers and runs all `test_`-prefixed arity-1 functions)
587587
- [x] `erlc` compilation step in build pipeline (per-file with graceful failure)
588588
- [x] Quicktest setup (`src/quicktest-beam/`, `Fable.Build/Quicktest/Beam.fs`)
@@ -992,6 +992,21 @@ alone eliminates the single hardest piece of the Fable.Python runtime.
992992
- **Stopwatch**: Runtime `fable_stopwatch.erl` using `erlang:monotonic_time(microsecond)`.
993993
Supports `StartNew`, `Start`, `Stop`, `Reset`, `Restart`, `Elapsed`, `ElapsedMilliseconds`,
994994
`IsRunning`, `Frequency`, `GetTimestamp`.
995+
- **FSharp.Reflection**: Full runtime support via `fable_reflection.erl` + compile-time type
996+
info generation in `Fable2Beam.Reflection.fs`. TypeInfo = Erlang map with `fullname`,
997+
`generics`, and optional `fields` (records) or `cases` (unions). PropertyInfo/CaseInfo are
998+
maps with `name`, `typ`, `tag`, `fields`. Reflection functions renamed to avoid Erlang BIF
999+
clashes: `is_tuple``is_tuple_type`, `is_function``is_function_type`. Union tag matching
1000+
uses integer tags (matching Beam's `{0, Field1, Field2}` representation). `MakeRecord`/
1001+
`MakeUnion` handle both plain lists and process-dict ref arrays. `GetRecordFields` resolves
1002+
concrete types through `TypeCast` wrappers via `getConcreteType` helper.
1003+
- **Async error wrapping**: `wrap_error/1` in `fable_async.erl` normalizes raw errors so
1004+
`.Message` accessor works: raw binaries (from `failwith`) → `#{message => Bin}`, maps
1005+
(from `raise (exn ...)`) pass through, refs pass through, everything else → formatted map.
1006+
Used in `catch_async`, `try_with`, and `try_finally`. `catch_async` uses integer tags
1007+
`{0, V}` / `{1, wrap_error(E)}` matching Beam's Choice union representation.
1008+
- **OperationCanceledException**: Added to the exception type pattern in Beam Replacements
1009+
alongside `BuiltinSystemException` and `KeyNotFoundException`.
9951010

9961011
## Future Improvements
9971012

src/Fable.Transforms/Beam/Fable2Beam.Reflection.fs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ let private strLit s =
1313
let private atomLit s =
1414
Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom s))
1515

16+
/// Create an integer literal Erlang expression
17+
let private intLit i =
18+
Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 i))
19+
1620
/// Build a type info map: #{fullname => <<"...">>, generics => [...]}
1721
let private makeTypeInfoMap (fullname: string) (generics: Beam.ErlExpr list) =
1822
Beam.ErlExpr.Map
@@ -21,6 +25,23 @@ let private makeTypeInfoMap (fullname: string) (generics: Beam.ErlExpr list) =
2125
atomLit "generics", Beam.ErlExpr.List generics
2226
]
2327

28+
/// Build a PropertyInfo map: #{name => <<"field_name">>, property_type => TypeInfo}
29+
let private makePropertyInfo (fieldName: string) (typeInfo: Beam.ErlExpr) =
30+
let erlName = Fable.Beam.Naming.sanitizeErlangName fieldName
31+
Beam.ErlExpr.Map [ atomLit "name", strLit erlName; atomLit "property_type", typeInfo ]
32+
33+
/// Build a CaseInfo map: #{tag => N, name => <<"CaseName">>, erl_tag => case_name, fields => [...]}
34+
let private makeCaseInfo (tag: int) (caseName: string) (fields: Beam.ErlExpr list) =
35+
let erlTag = Fable.Beam.Naming.sanitizeErlangName caseName
36+
37+
Beam.ErlExpr.Map
38+
[
39+
atomLit "tag", intLit tag
40+
atomLit "name", strLit caseName
41+
atomLit "erl_tag", atomLit erlTag
42+
atomLit "fields", Beam.ErlExpr.List fields
43+
]
44+
2445
/// Get the full name for a number kind
2546
let private getNumberFullName (kind: NumberKind) =
2647
match kind with
@@ -101,4 +122,40 @@ let rec transformTypeInfo
101122
| Fable.AnonymousRecordType _ -> makeTypeInfoMap "" []
102123
| Fable.DeclaredType(entRef, generics) ->
103124
let resolved = resolveGenerics generics
104-
makeTypeInfoMap entRef.FullName resolved
125+
126+
match com.TryGetEntity(entRef) with
127+
| Some ent when ent.IsFSharpRecord ->
128+
let fields =
129+
ent.FSharpFields
130+
|> List.map (fun fi ->
131+
let typeInfo = transformTypeInfo com r genMap fi.FieldType
132+
makePropertyInfo fi.Name typeInfo
133+
)
134+
135+
Beam.ErlExpr.Map
136+
[
137+
atomLit "fullname", strLit entRef.FullName
138+
atomLit "generics", Beam.ErlExpr.List resolved
139+
atomLit "fields", Beam.ErlExpr.List fields
140+
]
141+
| Some ent when ent.IsFSharpUnion ->
142+
let cases =
143+
ent.UnionCases
144+
|> List.mapi (fun i uci ->
145+
let caseFields =
146+
uci.UnionCaseFields
147+
|> List.map (fun fi ->
148+
let typeInfo = transformTypeInfo com r genMap fi.FieldType
149+
makePropertyInfo fi.Name typeInfo
150+
)
151+
152+
makeCaseInfo i uci.Name caseFields
153+
)
154+
155+
Beam.ErlExpr.Map
156+
[
157+
atomLit "fullname", strLit entRef.FullName
158+
atomLit "generics", Beam.ErlExpr.List resolved
159+
atomLit "cases", Beam.ErlExpr.List cases
160+
]
161+
| _ -> makeTypeInfoMap entRef.FullName resolved

src/Fable.Transforms/Beam/Fable2Beam.Util.fs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ let rec containsIdentRef (name: string) (expr: Expr) : bool =
5757
| Test(e, _, _) -> containsIdentRef name e
5858
| Get(e, _, _, _) -> containsIdentRef name e
5959
| Set(e, _, _, v, _) -> containsIdentRef name e || containsIdentRef name v
60+
| WhileLoop(guard, body, _) -> containsIdentRef name guard || containsIdentRef name body
61+
| ForLoop(_, start, limit, body, _, _) ->
62+
containsIdentRef name start
63+
|| containsIdentRef name limit
64+
|| containsIdentRef name body
65+
| TryCatch(body, catch, finalizer, _) ->
66+
containsIdentRef name body
67+
|| (catch
68+
|> Option.map (fun (_, e) -> containsIdentRef name e)
69+
|> Option.defaultValue false)
70+
|| (finalizer |> Option.map (containsIdentRef name) |> Option.defaultValue false)
6071
| Emit(emitInfo, _, _) -> emitInfo.CallInfo.Args |> List.exists (containsIdentRef name)
6172
| ObjectExpr(members, _, baseCall) ->
6273
members |> List.exists (fun m -> containsIdentRef name m.Body)
@@ -103,6 +114,14 @@ let isCapturedInClosure (name: string) (expr: Expr) : bool =
103114
| Test(e, _, _) -> check inClosure e
104115
| Get(e, _, _, _) -> check inClosure e
105116
| Set(e, _, _, v, _) -> check inClosure e || check inClosure v
117+
| WhileLoop(guard, body, _) -> check inClosure guard || check inClosure body
118+
| ForLoop(_, start, limit, body, _, _) -> check inClosure start || check inClosure limit || check inClosure body
119+
| TryCatch(body, catch, finalizer, _) ->
120+
check inClosure body
121+
|| (catch
122+
|> Option.map (fun (_, e) -> check inClosure e)
123+
|> Option.defaultValue false)
124+
|| (finalizer |> Option.map (check inClosure) |> Option.defaultValue false)
106125
| Emit(emitInfo, _, _) -> emitInfo.CallInfo.Args |> List.exists (check inClosure)
107126
| ObjectExpr(members, _, baseCall) ->
108127
members |> List.exists (fun m -> check true m.Body)

src/Fable.Transforms/Beam/Fable2Beam.fs

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,11 @@ let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.Er
265265
let allHoisted = appliedHoisted @ argsHoisted
266266

267267
let result =
268-
cleanArgs
269-
|> List.fold (fun fn arg -> Beam.ErlExpr.Apply(fn, [ arg ])) cleanApplied
268+
match cleanArgs with
269+
| [] -> Beam.ErlExpr.Apply(cleanApplied, [])
270+
| _ ->
271+
cleanArgs
272+
|> List.fold (fun fn arg -> Beam.ErlExpr.Apply(fn, [ arg ])) cleanApplied
270273

271274
result |> wrapWithHoisted allHoisted
272275

@@ -541,6 +544,8 @@ let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.Er
541544
lambdaBody,
542545
{ ctx' with LocalVars = ctx'.LocalVars.Add(arg.Name) }
543546
| Delegate(args, lambdaBody, _, _) ->
547+
let args = FSharp2Fable.Util.discardUnitArg args
548+
544549
args
545550
|> List.map (fun a -> Beam.PVar(capitalizeFirst a.Name |> sanitizeErlangVar)),
546551
args,
@@ -966,15 +971,6 @@ let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.Er
966971
}
967972
]
968973

969-
| _ ->
970-
let exprName = expr.GetType().Name
971-
972-
com.WarnOnlyOnce(
973-
$"Unhandled Fable expression type '%s{exprName}' — emitting placeholder atom. This may cause runtime errors."
974-
)
975-
976-
Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom $"todo_%s{exprName.ToLowerInvariant()}"))
977-
978974
and transformValue (com: IBeamCompiler) (ctx: Context) (value: ValueKind) : Beam.ErlExpr =
979975
match value with
980976
| StringConstant s -> Beam.ErlExpr.Literal(Beam.ErlLiteral.StringLit s)
@@ -1071,13 +1067,11 @@ and transformValue (com: IBeamCompiler) (ctx: Context) (value: ValueKind) : Beam
10711067
let erlValue = transformExpr com ctx value
10721068

10731069
match typ with
1074-
| GenericParam _
1075-
| Any ->
1076-
// Runtime decision: use smart constructor that wraps only ambiguous values
1077-
Beam.ErlExpr.Call(Some "fable_option", "some", [ erlValue ])
10781070
| _ when mustWrapOption typ ->
1079-
// Compile-time decision: we know wrapping is needed (e.g., Option<Option<T>>)
1080-
Beam.ErlExpr.Tuple [ Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom "some")); erlValue ]
1071+
// Use runtime smart constructor for consistency with library code (e.g., tryHead).
1072+
// Both the compiler-generated values and library-produced values go through the
1073+
// same fable_option:some/1, ensuring equal representation for nested options.
1074+
Beam.ErlExpr.Call(Some "fable_option", "some", [ erlValue ])
10811075
| _ -> erlValue
10821076

10831077
| NewOption(None, _typ, _isStruct) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom "undefined"))
@@ -1109,6 +1103,7 @@ and transformValue (com: IBeamCompiler) (ctx: Context) (value: ValueKind) : Beam
11091103
Beam.ErlExpr.Literal(Beam.ErlLiteral.Float d)
11101104
| NumberConstant(NumberValue.NativeInt i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 i))
11111105
| NumberConstant(NumberValue.UNativeInt i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 i))
1106+
| NumberConstant(NumberValue.BigInt i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.BigInt(string<bigint> i))
11121107
| NumberConstant(NumberValue.Decimal d, _) ->
11131108
// Decimal as fixed-scale integer: value × 10^28
11141109
let bits = System.Decimal.GetBits(d)
@@ -1869,8 +1864,28 @@ and transformCall (com: IBeamCompiler) (ctx: Context) (callee: Expr) (info: Call
18691864
Beam.ErlExpr.Apply(bundleCall, allArgs) |> wrapWithHoisted allHoisted
18701865
| None ->
18711866
if ctx.RecursiveBindings.Contains(ident.Name) || ctx.LocalVars.Contains(ident.Name) then
1872-
Beam.ErlExpr.Apply(Beam.ErlExpr.Variable(capitalizeFirst ident.Name |> sanitizeErlangVar), allArgs)
1873-
|> wrapWithHoisted allHoisted
1867+
let varExpr = Beam.ErlExpr.Variable(capitalizeFirst ident.Name |> sanitizeErlangVar)
1868+
1869+
let apply =
1870+
match allArgs with
1871+
| [] when
1872+
(match ident.Type with
1873+
| Fable.AST.Fable.Type.DelegateType _ -> true
1874+
| _ -> false)
1875+
->
1876+
// Zero-arg delegate Invoke (e.g. `d.Invoke()` on `delegate of unit -> int`).
1877+
// The underlying fun may be arity 0 (unit stripped at definition by discardUnitArg)
1878+
// or arity 1 (unit kept in Delegate AST node). Erlang enforces arity, so check
1879+
// at runtime. This is necessary because the Delegate AST node is shared by .NET
1880+
// delegates (Invoke strips unit via dropUnitCallArg) and callbacks like Lazy
1881+
// factories (called with explicit unit), preventing a uniform compile-time fix.
1882+
Beam.ErlExpr.Emit(
1883+
"case erlang:fun_info($0, arity) of {arity, 0} -> ($0)(); {arity, _} -> ($0)(ok) end",
1884+
[ varExpr ]
1885+
)
1886+
| _ -> Beam.ErlExpr.Apply(varExpr, allArgs)
1887+
1888+
apply |> wrapWithHoisted allHoisted
18741889
else
18751890
Beam.ErlExpr.Call(None, sanitizeErlangName ident.Name, allArgs)
18761891
|> wrapWithHoisted allHoisted

0 commit comments

Comments
 (0)