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 .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ jobs:
uses: erlef/setup-beam@v1
with:
otp-version: "27"
rebar3-version: "3"

- name: Fable Tests - Beam
run: ./build.sh test beam
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Make sure the following **requirements** are installed in your system:
- [Uv](https://docs.astral.sh/uv/)
- [Rust](https://www.rust-lang.org/tools/install)
- [Dart](https://dart.dev/get-dart)
- [Erlang/OTP](https://www.erlang.org/downloads) with [rebar3](https://rebar3.org/) (for Beam/Erlang target)

### Build

Expand Down
8 changes: 6 additions & 2 deletions src/Fable.Build/FableLibrary/Beam.fs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ type BuildFableLibraryBeam() =
)

override this.CopyStage() =
// Copy hand-written .erl runtime files to the output directory
// Copy hand-written .erl runtime files into the src/ subdirectory so
// they sit alongside the Fable-compiled .erl files for rebar3.
let srcDir = Path.Combine(this.OutDir, "src")
Directory.CreateDirectory(srcDir) |> ignore

for erlFile in Directory.GetFiles(this.SourceDir, "*.erl") do
File.Copy(erlFile, Path.Combine(this.OutDir, Path.GetFileName(erlFile)), overwrite = true)
File.Copy(erlFile, Path.Combine(srcDir, Path.GetFileName(erlFile)), overwrite = true)
85 changes: 36 additions & 49 deletions src/Fable.Build/Test/Beam.fs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ let private buildDir = Path.Resolve("temp", "tests", "Beam")
let private sourceDir = Path.Resolve("tests", "Beam")
let private testRunnerSrc = Path.Resolve("tests", "Beam", "erl_test_runner.erl")

// OTP app name derived from Fable.Tests.Beam.fsproj — computed the same way
// generateBeamScaffold does: replace dots/hyphens with underscores, lowercase.
let private testProjectName =
Path.GetFileNameWithoutExtension("Fable.Tests.Beam.fsproj").Replace('.', '_').Replace('-', '_').ToLowerInvariant()

let handle (args: string list) =
let isWatch = args |> List.contains "--watch"
let noDotnet = args |> List.contains "--no-dotnet"
Expand Down Expand Up @@ -49,63 +54,45 @@ let handle (args: string list) =
// Test against .NET
Command.Run("dotnet", "test -c Release", workingDirectory = sourceDir)

// Compile tests to Erlang
// Compile tests to Erlang.
// Fable automatically generates the rebar3 scaffold (rebar.config, .app.src,
// per-dep rebar.config and ebin/*.app) alongside the .erl files in src/.
Command.Fable(fableArgs, workingDirectory = buildDir)

// Copy test runner to build dir
File.Copy(testRunnerSrc, Path.Combine(buildDir, "erl_test_runner.erl"), overwrite = true)
// Copy test runner and native Erlang helpers into src/ so rebar3 compiles them
// as part of the project.
let buildSrcDir = Path.Combine(buildDir, "src")

File.Copy(testRunnerSrc, Path.Combine(buildSrcDir, "erl_test_runner.erl"), overwrite = true)

// Copy native Erlang test modules to build dir
let nativeErlDir = Path.Resolve("tests", "Beam", "erl")

if Directory.Exists(nativeErlDir) then
for erlFile in Directory.GetFiles(nativeErlDir, "*.erl") do
let fileName = Path.GetFileName(erlFile)
File.Copy(erlFile, Path.Combine(buildDir, fileName), overwrite = true)

// fable-library-beam files are auto-copied to fable_modules/fable-library-beam/ by the compiler
let fableModulesLibDir =
Path.Combine(buildDir, "fable_modules", "fable-library-beam")

// Compile fable-library-beam .erl files first
let mutable compileErrors = 0

if Directory.Exists(fableModulesLibDir) then
for erlFile in Directory.GetFiles(fableModulesLibDir, "*.erl") do
let fileName = Path.GetFileName(erlFile)

try
Command.Run(
"erlc",
$"+nowarn_ignored +nowarn_failed +nowarn_shadow_vars {fileName}",
workingDirectory = fableModulesLibDir
)
with _ ->
printfn $"WARNING: erlc failed for {fileName} (library), skipping"
compileErrors <- compileErrors + 1

// Compile test .erl files with library on code path
let erlFiles = Directory.GetFiles(buildDir, "*.erl")

for erlFile in erlFiles do
let fileName = Path.GetFileName(erlFile)

try
Command.Run(
"erlc",
$"+nowarn_ignored +nowarn_failed +nowarn_shadow_vars -pa fable_modules/fable-library-beam {fileName}",
workingDirectory = buildDir
)
with _ ->
printfn $"WARNING: erlc failed for {fileName}, skipping"
compileErrors <- compileErrors + 1

if compileErrors > 0 then
printfn $"WARNING: {compileErrors} file(s) failed to compile with erlc"

// Run Erlang test runner with library on code path
File.Copy(erlFile, Path.Combine(buildSrcDir, Path.GetFileName(erlFile)), overwrite = true)

// Compile everything with rebar3 using the generated scaffold.
Command.Run("rebar3", "compile", workingDirectory = buildDir)

// Collect all ebin directories produced by rebar3 for the -pa flags.
let buildDefaultLib = Path.Combine(buildDir, "_build", "default", "lib")

let ebinDirs =
if Directory.Exists(buildDefaultLib) then
Directory.GetDirectories(buildDefaultLib)
|> Array.map (fun d -> Path.Combine(d, "ebin"))
|> Array.filter Directory.Exists
else
[||]

let paArgs = ebinDirs |> Array.map (fun d -> $"-pa \"{d}\"") |> String.concat " "

// The test runner scans this directory for *_tests.beam modules.
let projectEbinDir = Path.Combine(buildDefaultLib, testProjectName, "ebin")

// Run Erlang test runner
Command.Run(
"erl",
"-noshell -pa . -pa fable_modules/fable-library-beam -eval \"erl_test_runner:main([\\\".\\\"])\" -s init stop",
$"-noshell {paArgs} -eval \"erl_test_runner:main([\\\"{projectEbinDir}\\\"])\" -s init stop",
workingDirectory = buildDir
)
1 change: 1 addition & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

* [Beam] Auto-generate rebar3 scaffold (`rebar.config`, `.app.src`) after compilation; `.erl` files now placed in `src/` subdirectories (by @dbrattli)
* [Beam] Support `[<ImportAll>]` + `[<Erase>]` interface pattern for typed FFI bindings (by @dbrattli)

### Fixed
Expand Down
151 changes: 131 additions & 20 deletions src/Fable.Cli/Main.fs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ module private Util =

if Naming.isInFableModules file then
// Library files in fable_modules: preserve subdirectory structure
// so they stay in fable_modules/fable-library-beam/
// so they stay in fable_modules/fable-library-beam/src/
let projDir = IO.Path.GetDirectoryName cliArgs.ProjectFile

let outDir =
Expand All @@ -168,16 +168,16 @@ module private Util =
let absPath = Imports.getTargetAbsolutePath pathResolver file projDir outDir
let dir = IO.Path.GetDirectoryName(absPath)
let fileName = Pipeline.Beam.normalizeFileName absPath
IO.Path.Combine(dir, fileName + fileExt)
IO.Path.Combine(dir, "src", fileName + fileExt)
else
// Project files: flat in outDir (Erlang uses flat module names)
// Project files: go into src/ so rebar3 picks them up
let fileName = Pipeline.Beam.normalizeFileName file

match cliArgs.OutDir with
| Some outDir -> IO.Path.Combine(IO.Path.GetFullPath outDir, fileName + fileExt)
| Some outDir -> IO.Path.Combine(IO.Path.GetFullPath outDir, "src", fileName + fileExt)
| None ->
let projDir = IO.Path.GetDirectoryName cliArgs.ProjectFile
IO.Path.Combine(projDir, fileName + fileExt)
IO.Path.Combine(projDir, "src", fileName + fileExt)

| lang ->
let changeExtension path fileExt =
Expand Down Expand Up @@ -962,13 +962,16 @@ let private areCompiledFilesUpToDate (state: State) (filesToCompile: string[]) =
Log.warning ("Cannot check timestamp of compiled files: " + er.Message)
false

// Library .erl files now live in src/ subdirectories (rebar3 convention).
let private erlLibSrcRelDir =
IO.Path.Combine("fable_modules", "fable-library-beam", "src")

let private compileBeamFiles (workingDir: string) =
let erlLibRelDir = IO.Path.Combine("fable_modules", "fable-library-beam")
let erlLibDir = IO.Path.Combine(workingDir, erlLibRelDir)
let erlLibSrcDir = IO.Path.Combine(workingDir, erlLibSrcRelDir)

// Compile fable-library-beam .erl files if any .beam files are missing
if IO.Directory.Exists(erlLibDir) then
let erlFiles = IO.Directory.GetFiles(erlLibDir, "*.erl")
if IO.Directory.Exists(erlLibSrcDir) then
let erlFiles = IO.Directory.GetFiles(erlLibSrcDir, "*.erl")

let needsCompile =
erlFiles
Expand All @@ -979,31 +982,135 @@ let private compileBeamFiles (workingDir: string) =

Process.runSyncWithEnv
[]
erlLibDir
erlLibSrcDir
"erlc"
("+nowarn_ignored" :: "+nowarn_failed" :: "+nowarn_shadow_vars" :: erlFileNames)
|> ignore

// Compile all .erl files in the working dir (project output files)
// Compile all .erl files in src/ (project output files go there now)
let mainSrcDir = IO.Path.Combine(workingDir, "src")

let mainErlFiles =
IO.Directory.GetFiles(workingDir, "*.erl")
|> Array.map IO.Path.GetFileName
|> Array.toList
if IO.Directory.Exists(mainSrcDir) then
IO.Directory.GetFiles(mainSrcDir, "*.erl")
|> Array.map IO.Path.GetFileName
|> Array.toList
else
[]

if not mainErlFiles.IsEmpty then
// -pa path is relative to the directory erlc is invoked from (mainSrcDir)
let relLibPath = IO.Path.Combine("..", "fable_modules", "fable-library-beam", "src")

Process.runSyncWithEnv
[]
workingDir
mainSrcDir
"erlc"
("+nowarn_ignored"
:: "+nowarn_failed"
:: "+nowarn_shadow_vars"
:: "-pa"
:: erlLibRelDir
:: relLibPath
:: mainErlFiles)
|> ignore

erlLibRelDir
let private generateBeamScaffold (cliArgs: CliArgs) =
let outDir =
cliArgs.OutDir
|> Option.defaultWith (fun () -> IO.Path.GetDirectoryName cliArgs.ProjectFile)

let projectName =
IO.Path.GetFileNameWithoutExtension(cliArgs.ProjectFile)
|> Pipeline.Beam.normalizeAppName

let generatedMarker = "%% Generated by Fable - safe to regenerate"
let fableModulesDir = IO.Path.Combine(outDir, "fable_modules")

let isFableGenerated (path: string) =
if IO.File.Exists(path) then
use reader = new IO.StreamReader(path)
let firstLine = reader.ReadLine()

firstLine <> null
&& firstLine.StartsWith(generatedMarker, StringComparison.Ordinal)
else
false

let writeIfChanged (path: string) (content: string) =
if not (IO.File.Exists(path)) || IO.File.ReadAllText(path) <> content then
IO.File.WriteAllText(path, content)

let appContent (appName: string) (version: string) =
$"""{generatedMarker}
{{application, {appName}, [
{{description, "Fable compiled application"}},
{{vsn, "{version}"}},
{{modules, []}},
{{registered, []}},
{{applications, [kernel, stdlib]}}
]}}.
"""

let depRebarConfig () =
$"""{generatedMarker}
{{erl_opts, [debug_info]}}.
"""

// Discover fable_modules dep directories
let deps =
if IO.Directory.Exists(fableModulesDir) then
IO.Directory.GetDirectories(fableModulesDir)
|> Array.map (fun dir ->
let dirName = IO.Path.GetFileName(dir)
dirName, Pipeline.Beam.deriveDepAppName dirName, Pipeline.Beam.extractDepVersion dirName
)
|> Array.toList
else
[]

// Write src/<project>.app.src
let srcDir = IO.Path.Combine(outDir, "src")
IO.Directory.CreateDirectory(srcDir) |> ignore
writeIfChanged (IO.Path.Combine(srcDir, projectName + ".app.src")) (appContent projectName "0.1.0")

// Write root rebar.config
let rootRebarConfig = IO.Path.Combine(outDir, "rebar.config")

let rootRebarConfigContent () =
$"""{generatedMarker}
{{erl_opts, [debug_info]}}.

{{project_app_dirs, [".", "fable_modules/*"]}}.
"""

if not (IO.File.Exists(rootRebarConfig)) then
IO.File.WriteAllText(rootRebarConfig, rootRebarConfigContent ())
Log.always $"[BEAM] Generated rebar.config"
elif isFableGenerated rootRebarConfig then
let newContent = rootRebarConfigContent ()

if IO.File.ReadAllText(rootRebarConfig) <> newContent then
IO.File.WriteAllText(rootRebarConfig, newContent)
Log.always "[BEAM] Updated rebar.config"
else
Log.warning
"[BEAM] rebar.config exists and was not generated by Fable — skipping update. \
Delete it to let Fable regenerate it."

// Write per-dep src/<app>.app.src and rebar.config
for (dirName, appName, version) in deps do
let depDir = IO.Path.Combine(fableModulesDir, dirName)
let depSrcDir = IO.Path.Combine(depDir, "src")
IO.Directory.CreateDirectory(depSrcDir) |> ignore

// src/<app>.app.src — always regenerated; rebar3 uses this when building the dep as a project app
writeIfChanged (IO.Path.Combine(depSrcDir, appName + ".app.src")) (appContent appName version)

// dep rebar.config — regenerated when absent or Fable-generated
let depRebarConfigPath = IO.Path.Combine(depDir, "rebar.config")

if not (IO.File.Exists(depRebarConfigPath)) || isFableGenerated depRebarConfigPath then
writeIfChanged depRebarConfigPath (depRebarConfig ())

let private runProcessAndForget (cliArgs: CliArgs) (runProc: RunProcess) =
let workingDir = cliArgs.RootDir
Expand Down Expand Up @@ -1056,15 +1163,15 @@ let private checkRunProcess (state: State) (projCracked: ProjectCracked) (compil
"node", lastFilePath :: runProc.Args
| Beam, Naming.placeholder ->
let moduleName = IO.Path.GetFileNameWithoutExtension(findLastFileFullPath ())
let erlLibRelDir = compileBeamFiles workingDir
compileBeamFiles workingDir

"erl",
[
"-noshell"
"-pa"
"."
"src" // project .beam files are compiled into src/
"-pa"
erlLibRelDir
erlLibSrcRelDir // library .beam files are in fable_modules/.../src/
"-eval"
$"{moduleName}:main()"
"-s"
Expand Down Expand Up @@ -1353,6 +1460,10 @@ let private compilationCycle (state: State) (changes: ISet<string>) =
| _ -> return 0
}

// Generate rebar3 scaffold for BEAM target after successful compilation
if cliArgs.CompilerOptions.Language = Beam && not hasError then
generateBeamScaffold cliArgs

// Run process
let exitCode, state =
if hasError then
Expand Down
Loading
Loading