diff --git a/src/Fable.Transforms/Beam/FABLE-BEAM.md b/src/Fable.Transforms/Beam/FABLE-BEAM.md index 5cb895b560..21fce75e79 100644 --- a/src/Fable.Transforms/Beam/FABLE-BEAM.md +++ b/src/Fable.Transforms/Beam/FABLE-BEAM.md @@ -1000,6 +1000,17 @@ alone eliminates the single hardest piece of the Fable.Python runtime. `fable_utils:new_ref([...])`. `derefArr`/`wrapArr` helpers in Replacements convert between refs and plain lists for bulk operations. Binary comparison operators on arrays use `fable_comparison:compare(A, B) op 0`. TypeTest for arrays uses `is_reference`. + When an array *literal* flows directly into an FFI/Emit binding that derefs its + argument (e.g. `maps:from_list(erlang:get($0))`), the naive output is + `...erlang:get(fable_utils:new_ref([...]))` — a pointless process-dict round-trip + on an immutable literal (it also leaks an un-erased process-dict entry). + `simplifyArrayRefDerefs` in `Fable2Beam.fs` cancels it on the Beam AST when + building an `Emit` node: if an argument is an inline `new_ref(list)` and *every* + occurrence of its `$N` placeholder in the macro template is deref-wrapped + (`erlang:get($N)`/`get($N)`), the deref is dropped from the template and the + underlying list is passed directly. Gating on the argument's AST shape (rather than + parsing rendered Erlang) keeps it robust; a ref-typed *variable* (a bound/mutable + array) keeps its deref. - **Byte arrays via atomics**: Byte arrays (`Array`) use Erlang's `atomics` module for true O(1) mutable read/write. Represented as `{byte_array, Size, AtomicsRef}` tuples. Runtime helpers in `fable_utils.erl` handle both direct tuples and process-dict diff --git a/src/Fable.Transforms/Beam/Fable2Beam.fs b/src/Fable.Transforms/Beam/Fable2Beam.fs index d88a12b524..850086fc8f 100644 --- a/src/Fable.Transforms/Beam/Fable2Beam.fs +++ b/src/Fable.Transforms/Beam/Fable2Beam.fs @@ -19,6 +19,44 @@ let rec mustWrapOption = | Option _ -> true | _ -> false +/// Simplify the process-dict ref round-trip that arises when an immutable array +/// *literal* is passed to an FFI/Emit binding that immediately derefs it. +/// +/// Fable-BEAM materialises an array literal as a fresh, inline process-dict ref +/// (`fable_utils:new_ref([...])`). A binding that reads it back with `erlang:get($N)` +/// (or bare `get($N)`) therefore produces `erlang:get(fable_utils:new_ref([...]))` — +/// a round-trip that yields the literal straight back while leaking a never-erased +/// process-dict entry. +/// +/// This is recognised on the Beam AST: when argument N is an inline `new_ref(list)` +/// and *every* occurrence of `$N` in the (short, authored) macro template is wrapped +/// by a deref, the deref is dropped from the template and the underlying list is +/// passed directly. Gating on the argument's AST shape — instead of parsing rendered +/// Erlang — keeps this robust; a ref-typed *variable* (a bound/mutable array) is left +/// untouched, so its deref is preserved. +let private simplifyArrayRefDerefs (macro: string) (args: Beam.ErlExpr list) : string * Beam.ErlExpr list = + let folder (macroAcc: string, argsAcc) (i, arg) = + match arg with + | Beam.ErlExpr.Call(Some "fable_utils", "new_ref", [ inner ]) -> + let ph = $"$%d{i}" + // A control-char sentinel (never present in a macro template) marks the + // deref-wrapped placeholders. `erlang:get` is replaced first so its inner + // `get(` isn't consumed by the bare form below. + let sentinel = "\u0000" + + let stripped = + macroAcc.Replace($"erlang:get(%s{ph})", sentinel).Replace($"get(%s{ph})", sentinel) + + // Collapse only when the placeholder was present and every occurrence was + // deref-wrapped — a surviving bare `$N` means some use site still needs the ref. + if stripped.Contains(sentinel) && not (stripped.Contains(ph)) then + stripped.Replace(sentinel, ph), argsAcc @ [ inner ] + else + macroAcc, argsAcc @ [ arg ] + | _ -> macroAcc, argsAcc @ [ arg ] + + args |> List.indexed |> List.fold folder (macro, []) + let unwrapOptionalArg (arg: Fable.Expr) = match arg with | Fable.Value(Fable.NewOption(Some inner, _, _), _) -> inner @@ -741,7 +779,8 @@ let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.Er transformReceive com ctx emitInfo typ else let args = emitInfo.CallInfo.Args |> List.map (transformExpr com ctx) - Beam.ErlExpr.Emit(emitInfo.Macro, args) + let macro, args = simplifyArrayRefDerefs emitInfo.Macro args + Beam.ErlExpr.Emit(macro, args) | TryCatch(body, catch_, finalizer, _range) -> let erlBody = transformExpr com ctx body diff --git a/tests/Beam/InteropTests.fs b/tests/Beam/InteropTests.fs index eb670c1f36..6fc1582c73 100644 --- a/tests/Beam/InteropTests.fs +++ b/tests/Beam/InteropTests.fs @@ -121,6 +121,16 @@ let boolToString (x: bool) : string = nativeOnly [ <<\"none\">>; Value -> Value end")>] let emitWithValueCaseVar (x: string option) : string = nativeOnly +// FFI binding that derefs its array argument. Fable-BEAM represents arrays as +// process-dict refs, so an array literal passed here would otherwise generate a +// `erlang:get(fable_utils:new_ref([...]))` round-trip; the compiler must collapse +// that to a plain list so the BIF receives `[{...}]` directly. +[] +let mapFromPairs (pairs: (string * int) array) : obj = nativeOnly + +[] +let mapGet (key: string) (m: obj) : int = nativeOnly + #endif // ============================================================ @@ -180,6 +190,17 @@ let ``test emitErlExpr can call Erlang functions`` () = () #endif +[] +let ``test array literal passed to deref FFI binding round-trips correctly`` () = +#if FABLE_COMPILER + // The literal flows straight into maps:from_list without a process-dict ref. + let m = mapFromPairs [| ("a", 1); ("b", 2) |] + equal 1 (mapGet "a" m) + equal 2 (mapGet "b" m) +#else + () +#endif + [] let ``test Emit with case expression called twice does not leak variables`` () = #if FABLE_COMPILER