Skip to content

Commit ab82e32

Browse files
authored
[Beam] Fix ImportAll generating invalid Erlang when used as value (#4412)
1 parent af59c6b commit ab82e32

5 files changed

Lines changed: 132 additions & 45 deletions

File tree

src/Fable.Cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
* [Beam] Fix "no effect" warning for pure BIF calls (`self/0`, `node/0`) in non-final block positions (by @dbrattli)
1616
* [Beam] Fix `reraise()` generating unbound `MatchValue` variable — use raw Erlang reason variable for re-throw (by @dbrattli)
1717
* [Beam] Fix `Erlang.receive<'T>()` resolving to timeout overload due to F# unit argument (by @dbrattli)
18+
* [Beam] Fix `[<ImportAll>]` generating invalid `module:*()` Erlang code when binding is used as a value (by @dbrattli)
1819

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

src/Fable.Compiler/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
* [Beam] Fix "no effect" warning for pure BIF calls (`self/0`, `node/0`) in non-final block positions (by @dbrattli)
1616
* [Beam] Fix `reraise()` generating unbound `MatchValue` variable — use raw Erlang reason variable for re-throw (by @dbrattli)
1717
* [Beam] Fix `Erlang.receive<'T>()` resolving to timeout overload due to F# unit argument (by @dbrattli)
18+
* [Beam] Fix `[<ImportAll>]` generating invalid `module:*()` Erlang code when binding is used as a value (by @dbrattli)
1819

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

src/Fable.Transforms/Beam/Fable2Beam.fs

Lines changed: 72 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,52 +1189,66 @@ let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.Er
11891189
Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom "ok"))
11901190

11911191
| Import(importInfo, typ, _range) ->
1192-
// Standalone import (function reference from another module, not a direct call).
1193-
// Generate a lambda wrapper: fun(A1, ..., AN) -> module:function(A1, ..., AN) end
11941192
let moduleName = resolveImportModuleName com importInfo.Path
11951193

1196-
let funcName = sanitizeErlangName importInfo.Selector
1197-
1198-
// Count arity from the function type (follows all nested LambdaType levels)
1199-
let rec functionArity (t: Fable.AST.Fable.Type) =
1200-
match t with
1201-
| Fable.AST.Fable.Type.LambdaType(_, returnType) -> 1 + functionArity returnType
1202-
| Fable.AST.Fable.Type.DelegateType(argTypes, _) -> argTypes.Length
1203-
| _ -> 0
1204-
1205-
// Determine the actual Erlang arity of the imported function.
1206-
// For MemberImport, use NonCurriedArgTypes which gives us the actual parameter count.
1207-
// This is critical because functionArity(typ) follows the full curried type chain
1208-
// (including return type nesting), which over-counts when the return type is itself
1209-
// a function type (e.g., HttpHandler -> HttpHandler -> HttpHandler where HttpHandler
1210-
// is a function type — functionArity returns 4 but actual Erlang arity is 2).
1211-
let arity =
1212-
match importInfo.Kind with
1213-
| Fable.AST.Fable.MemberImport(Fable.AST.Fable.MemberRef(_, info)) ->
1214-
match info.NonCurriedArgTypes with
1215-
| Some argTypes -> argTypes.Length
1216-
| None -> 0 // Value binding (no explicit params) → 0-arity in Erlang
1217-
| _ -> functionArity typ
1218-
1219-
if arity = 0 then
1220-
// Not a function or value binding — call with no args to get the value
1221-
Beam.ErlExpr.Call(moduleName, funcName, [])
1222-
else
1223-
// Generate lambda wrapper for the function reference
1224-
let counter = com.IncrementCounter()
1225-
1226-
let argNames = List.init arity (fun i -> $"Import_arg_%d{i}_%d{counter}")
1227-
let argPats = argNames |> List.map Beam.PVar
1228-
let argExprs = argNames |> List.map Beam.ErlExpr.Variable
1194+
if importInfo.Selector = "*" then
1195+
// ImportAll: represents a whole module (e.g., [<ImportAll("maps")>] let maps: IMaps = nativeOnly).
1196+
// When used as a value (assigned to variable), generate a tagged tuple
1197+
// {fable_import_all, module_atom} that fable_utils:iface_get recognizes
1198+
// and dispatches dynamically via erlang:make_fun/3.
1199+
let modAtom =
1200+
Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom(moduleName |> Option.defaultValue "unknown")))
12291201

1230-
Beam.ErlExpr.Fun
1202+
Beam.ErlExpr.Tuple
12311203
[
1232-
{
1233-
Patterns = argPats
1234-
Guard = []
1235-
Body = [ Beam.ErlExpr.Call(moduleName, funcName, argExprs) ]
1236-
}
1204+
Beam.ErlExpr.Literal(Beam.ErlLiteral.AtomLit(Beam.Atom "fable_import_all"))
1205+
modAtom
12371206
]
1207+
else
1208+
// Standalone import (function reference from another module, not a direct call).
1209+
// Generate a lambda wrapper: fun(A1, ..., AN) -> module:function(A1, ..., AN) end
1210+
let funcName = sanitizeErlangName importInfo.Selector
1211+
1212+
// Count arity from the function type (follows all nested LambdaType levels)
1213+
let rec functionArity (t: Fable.AST.Fable.Type) =
1214+
match t with
1215+
| Fable.AST.Fable.Type.LambdaType(_, returnType) -> 1 + functionArity returnType
1216+
| Fable.AST.Fable.Type.DelegateType(argTypes, _) -> argTypes.Length
1217+
| _ -> 0
1218+
1219+
// Determine the actual Erlang arity of the imported function.
1220+
// For MemberImport, use NonCurriedArgTypes which gives us the actual parameter count.
1221+
// This is critical because functionArity(typ) follows the full curried type chain
1222+
// (including return type nesting), which over-counts when the return type is itself
1223+
// a function type (e.g., HttpHandler -> HttpHandler -> HttpHandler where HttpHandler
1224+
// is a function type — functionArity returns 4 but actual Erlang arity is 2).
1225+
let arity =
1226+
match importInfo.Kind with
1227+
| Fable.AST.Fable.MemberImport(Fable.AST.Fable.MemberRef(_, info)) ->
1228+
match info.NonCurriedArgTypes with
1229+
| Some argTypes -> argTypes.Length
1230+
| None -> 0 // Value binding (no explicit params) → 0-arity in Erlang
1231+
| _ -> functionArity typ
1232+
1233+
if arity = 0 then
1234+
// Not a function or value binding — call with no args to get the value
1235+
Beam.ErlExpr.Call(moduleName, funcName, [])
1236+
else
1237+
// Generate lambda wrapper for the function reference
1238+
let counter = com.IncrementCounter()
1239+
1240+
let argNames = List.init arity (fun i -> $"Import_arg_%d{i}_%d{counter}")
1241+
let argPats = argNames |> List.map Beam.PVar
1242+
let argExprs = argNames |> List.map Beam.ErlExpr.Variable
1243+
1244+
Beam.ErlExpr.Fun
1245+
[
1246+
{
1247+
Patterns = argPats
1248+
Guard = []
1249+
Body = [ Beam.ErlExpr.Call(moduleName, funcName, argExprs) ]
1250+
}
1251+
]
12381252

12391253
and transformValue (com: IBeamCompiler) (ctx: Context) (value: ValueKind) : Beam.ErlExpr =
12401254
match value with
@@ -2313,6 +2327,18 @@ and transformCall (com: IBeamCompiler) (ctx: Context) (callee: Expr) (info: Call
23132327
| "toSeq" -> Beam.ErlExpr.Call(Some "maps", "to_list", cleanArgs)
23142328
| _ -> Beam.ErlExpr.Call(Some "maps", sanitizeErlangName selector, cleanArgs)
23152329
|> wrapWithHoisted hoisted
2330+
| "*" ->
2331+
// ImportAll: selector is "*" meaning the callee is a whole-module import.
2332+
// Use MemberRef to get the actual function name being called.
2333+
match info.MemberRef with
2334+
| Some(Fable.AST.Fable.MemberRef(_, memberInfo)) ->
2335+
let funcName = sanitizeErlangName memberInfo.CompiledName
2336+
let args = info.Args |> List.map (transformExpr com ctx)
2337+
let hoisted, cleanArgs = hoistBlocksFromArgs args
2338+
2339+
Beam.ErlExpr.Call(importModuleName, funcName, cleanArgs)
2340+
|> wrapWithHoisted hoisted
2341+
| _ -> failwith "ImportAll call without MemberRef — cannot resolve function name"
23162342
| selector ->
23172343
let args = info.Args |> List.map (transformExpr com ctx)
23182344
let hoisted, cleanArgs = hoistBlocksFromArgs args
@@ -2436,12 +2462,14 @@ and transformCall (com: IBeamCompiler) (ctx: Context) (callee: Expr) (info: Call
24362462
let allHoisted = calleeHoisted @ argsHoisted
24372463

24382464
if isInterfaceExpr then
2439-
// Interface method dispatch: (fable_utils:iface_get(method_name, Obj))(Args)
2440-
// Works for both object expressions (maps) and class instances (refs)
2465+
// Interface method dispatch: (fable_utils:iface_get(method_name, arity, Obj))(Args)
2466+
// Works for object expressions (maps), class instances (refs), and ImportAll modules.
2467+
// Passing arity resolves ambiguity when a module exports the same name at multiple arities.
24412468
let methodAtom = atomLit (sanitizeErlangName fieldInfo.Name)
2469+
let arityLit = Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 cleanArgs.Length))
24422470

24432471
let lookup =
2444-
Beam.ErlExpr.Call(Some "fable_utils", "iface_get", [ methodAtom; cleanCallee ])
2472+
Beam.ErlExpr.Call(Some "fable_utils", "iface_get", [ methodAtom; arityLit; cleanCallee ])
24452473

24462474
Beam.ErlExpr.Apply(lookup, cleanArgs) |> wrapWithHoisted allHoisted
24472475
else

src/fable-library-beam/fable_utils.erl

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
-module(fable_utils).
22
-export([
33
iface_get/2,
4+
iface_get/3,
45
apply_curried/2,
56
new_ref/1,
67
safe_dispose/1,
@@ -29,7 +30,8 @@
2930
div_rem/3
3031
]).
3132

32-
-spec iface_get(atom(), map() | reference()) -> term().
33+
-spec iface_get(atom(), map() | reference() | {fable_import_all, atom()}) -> term().
34+
-spec iface_get(atom(), non_neg_integer(), {fable_import_all, atom()}) -> term().
3335
-spec apply_curried(fun(), list()) -> term().
3436
-spec new_ref(term()) -> reference().
3537
-spec safe_dispose(term()) -> ok.
@@ -60,6 +62,20 @@
6062
%% Interface dispatch: works for both object expressions (maps) and class instances (refs).
6163
%% Class interface property getters are stored as {getter, Fun} tagged thunks — call Fun().
6264
%% ObjectExpr property getters are stored as plain values — return directly.
65+
%% ImportAll modules are tagged as {fable_import_all, ModuleAtom} and dispatched via
66+
%% erlang:make_fun/3. The 3-arity overload passes explicit arity to avoid ambiguity
67+
%% when a module exports the same name at multiple arities (e.g., maps:get/2 and get/3).
68+
iface_get(Name, Arity, {fable_import_all, Mod}) ->
69+
erlang:make_fun(Mod, Name, Arity);
70+
iface_get(Name, _Arity, Obj) ->
71+
iface_get(Name, Obj).
72+
73+
iface_get(Name, {fable_import_all, Mod}) ->
74+
Exports = Mod:module_info(exports),
75+
case lists:keyfind(Name, 1, Exports) of
76+
{Name, Arity} -> erlang:make_fun(Mod, Name, Arity);
77+
false -> erlang:error({no_export, Mod, Name})
78+
end;
6379
iface_get(Name, Obj) when is_map(Obj) -> iface_unwrap(maps:get(Name, Obj));
6480
iface_get(Name, Ref) -> iface_unwrap(maps:get(Name, get(Ref))).
6581

tests/Beam/InteropTests.fs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ type INativeCode =
5454
[<ImportAll("native_code")>]
5555
let nativeCode: INativeCode = nativeOnly
5656

57+
// ImportAll with Erlang stdlib module (maps)
58+
[<Erase>]
59+
type IMaps =
60+
abstract from_list: list: obj -> obj
61+
abstract get: key: obj * map: obj -> obj
62+
63+
[<ImportAll("maps")>]
64+
let erlangMaps: IMaps = nativeOnly
65+
5766
// ============================================================
5867
// Erased union tests
5968
// ============================================================
@@ -222,6 +231,38 @@ let ``test ImportAll with Erase interface calls string method`` () =
222231
()
223232
#endif
224233

234+
[<Fact>]
235+
let ``test ImportAll with stdlib module single-arg method`` () =
236+
#if FABLE_COMPILER
237+
// maps:from_list([{key, value}]) should work via ImportAll
238+
let list: obj = emitErlExpr () "[{<<\"a\">>, 1}]"
239+
let result = erlangMaps.from_list(list)
240+
let value: obj = erlangMaps.get(box "a", result)
241+
equal 1 (unbox<int> value)
242+
#else
243+
()
244+
#endif
245+
246+
[<Fact>]
247+
let ``test ImportAll binding assigned to local variable`` () =
248+
#if FABLE_COMPILER
249+
// Test that ImportAll works when assigned to a local let binding
250+
let m = nativeCode
251+
m.getName () |> equal "native_code" // 0-arity (property-style)
252+
m.addValues (3, 4) |> equal 7 // multi-arg method
253+
#else
254+
()
255+
#endif
256+
257+
[<Fact>]
258+
let ``test ImportAll used with pipe operator`` () =
259+
#if FABLE_COMPILER
260+
// Test that ImportAll works when used in a pipeline
261+
(3, 4) |> nativeCode.addValues |> equal 7
262+
#else
263+
()
264+
#endif
265+
225266
[<Fact>]
226267
let ``test Erased types can have members`` () =
227268
#if FABLE_COMPILER

0 commit comments

Comments
 (0)