diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f13b78849..91da55bcb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index 255ef229e..32cafb98b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Fable.Build/FableLibrary/Beam.fs b/src/Fable.Build/FableLibrary/Beam.fs index 4d037bfb1..a8a24ded7 100644 --- a/src/Fable.Build/FableLibrary/Beam.fs +++ b/src/Fable.Build/FableLibrary/Beam.fs @@ -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) diff --git a/src/Fable.Build/Test/Beam.fs b/src/Fable.Build/Test/Beam.fs index 3dec72e38..169b1122f 100644 --- a/src/Fable.Build/Test/Beam.fs +++ b/src/Fable.Build/Test/Beam.fs @@ -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" @@ -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 ) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index c199c6953..802aca9b7 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -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 `[]` + `[]` interface pattern for typed FFI bindings (by @dbrattli) ### Fixed diff --git a/src/Fable.Cli/Main.fs b/src/Fable.Cli/Main.fs index a8bcc2e98..6a7617769 100644 --- a/src/Fable.Cli/Main.fs +++ b/src/Fable.Cli/Main.fs @@ -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 = @@ -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 = @@ -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 @@ -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/.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.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.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 @@ -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" @@ -1353,6 +1460,10 @@ let private compilationCycle (state: State) (changes: ISet) = | _ -> 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 diff --git a/src/Fable.Cli/Pipeline.fs b/src/Fable.Cli/Pipeline.fs index b46fdd7ad..58a9a9ebd 100644 --- a/src/Fable.Cli/Pipeline.fs +++ b/src/Fable.Cli/Pipeline.fs @@ -508,6 +508,40 @@ module Beam = Path.GetFileNameWithoutExtension(path).Replace(".", "_").Replace("-", "_") |> Naming.applyCaseRule Core.CaseRules.SnakeCase + /// True when a dot-segment looks like a version number (starts with a digit). + let private isVersionSegment (s: string) = s.Length > 0 && Char.IsDigit(s.[0]) + + /// Normalize a name to a valid OTP application name (lowercase snake_case, no leading/trailing underscores). + /// "Fable.Tests.Beam" → "fable_tests_beam" + /// "fable-library-beam" → "fable_library_beam" + let normalizeAppName (name: string) = + name.Replace('.', '_').Replace('-', '_').ToLowerInvariant().Trim('_') + + /// Derive an OTP application name from a fable_modules directory name. + /// "Fable.Logging.0.10.0" → "fable_logging" + /// "fable-library-beam" → "fable_library_beam" + /// "Fable.Python.4.0.0-theta-003" → "fable_python" + let deriveDepAppName (dirName: string) = + let dotParts = dirName.Split('.') + + let namePart = + match dotParts |> Array.tryFindIndex isVersionSegment with + | Some idx when idx > 0 -> dotParts.[.. idx - 1] |> String.concat "." + | _ -> dirName + + normalizeAppName namePart + + /// Extract the version string from a fable_modules directory name. + /// "Fable.Logging.0.10.0" → "0.10.0" + /// "Fable.Python.4.0.0-theta-003" → "4.0.0-theta-003" + /// "fable-library-beam" → "0.1.0" + let extractDepVersion (dirName: string) = + let dotParts = dirName.Split('.') + + match dotParts |> Array.tryFindIndex isVersionSegment with + | Some idx -> dotParts.[idx..] |> String.concat "." + | None -> "0.1.0" + let getTargetPath (cliArgs: CliArgs) (targetPath: string) = let fileExt = cliArgs.CompilerOptions.FileExtension let targetDir = Path.GetDirectoryName(targetPath)