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
110 changes: 100 additions & 10 deletions src/Fable.Cli/Main.fs
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,13 @@ module private Util =
| Beam ->
let fileExt = cliArgs.CompilerOptions.FileExtension

// An Erlang module's file name must match the `-module` atom it declares, so the
// output file is named after the module, not after the F# source file.
let fileName = Pipeline.Beam.moduleName cliArgs file + fileExt

if Naming.isInFableModules file then
// Library files in fable_modules: preserve subdirectory structure
// so they stay in fable_modules/fable-library-beam/src/
// Package and library sources in fable_modules: preserve the containing
// directory so each stays its own OTP app (fable_modules/<dep>/src/)
let projDir = IO.Path.GetDirectoryName cliArgs.ProjectFile

let outDir =
Expand All @@ -167,17 +171,14 @@ 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, "src", fileName + fileExt)
IO.Path.Combine(dir, "src", fileName)
else
// 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, "src", fileName + fileExt)
| Some outDir -> IO.Path.Combine(IO.Path.GetFullPath outDir, "src", fileName)
| None ->
let projDir = IO.Path.GetDirectoryName cliArgs.ProjectFile
IO.Path.Combine(projDir, "src", fileName + fileExt)
IO.Path.Combine(projDir, "src", fileName)

| lang ->
let changeExtension path fileExt =
Expand Down Expand Up @@ -1014,7 +1015,61 @@ let private compileBeamFiles (workingDir: string) =
:: mainErlFiles)
|> ignore

let private generateBeamScaffold (cliArgs: CliArgs) =
/// Erlang's module namespace is flat and global, and an .erl file must be named after the module
/// it declares. Two source files that map to the same module name therefore write the same output
/// file, and whichever is compiled last silently overwrites the other — its functions simply
/// disappear from the output. A module named after one of OTP's own is just as fatal: the two
/// names are the same atom, and whichever module loses the lookup is unreachable. Both failures
/// surface only as `undef` at runtime, so fail here instead, while we can still say which files
/// are to blame.
let private checkBeamModuleNames (cliArgs: CliArgs) (sourceFiles: string seq) =
let modules =
sourceFiles
|> Seq.filter Fable.Beam.Naming.isGeneratedModuleSource
|> Seq.map (fun path -> Pipeline.Beam.moduleName cliArgs path, path)
|> Seq.toArray

let fail (message: string) (details: string seq) =
let details = details |> String.concat Log.newLine
Fable.FableError($"{message}{Log.newLine}{details}") |> raise

let duplicates =
modules
|> Array.groupBy fst
|> Array.choose (fun (moduleName, files) ->
if files.Length > 1 then
let paths =
files
|> Array.map (fun (_, path) -> " " + File.relPathToCurDir path)
|> String.concat Log.newLine

Some $"'{moduleName}' is generated from more than one file:{Log.newLine}{paths}"
else
None
)

if not (Array.isEmpty duplicates) then
fail
("Erlang module names must be unique across the whole compilation: the module namespace "
+ "is global and each module is written to a file named after it, so one of these would "
+ "silently overwrite the other. Rename one of the files, or move it to a different "
+ "assembly.")
duplicates

let shadowed =
modules
|> Array.filter (fst >> Fable.Beam.Naming.otpModules.Contains)
|> Array.map (fun (moduleName, path) -> $" '{moduleName}' from {File.relPathToCurDir path}")

if not (Array.isEmpty shadowed) then
fail
("These files generate Erlang modules that shadow OTP's own: the module namespace is "
+ "global, so the generated module and OTP's would be the same atom and one of them "
+ "unreachable. Rename the file, or move it to a directory or assembly whose name does "
+ "not produce a clashing module name.")
shadowed

let private generateBeamScaffold (cliArgs: CliArgs) (entryModule: string) =
let outDir =
cliArgs.OutDir
|> Option.defaultWith (fun () -> IO.Path.GetDirectoryName cliArgs.ProjectFile)
Expand Down Expand Up @@ -1073,6 +1128,29 @@ let private generateBeamScaffold (cliArgs: CliArgs) =
IO.Directory.CreateDirectory(srcDir) |> ignore
writeIfChanged (IO.Path.Combine(srcDir, projectName + ".app.src")) (appContent projectName "0.1.0")

// Module names are qualified by the app they belong to, so the entry point of a project
// compiled from Program.fs is `my_app_program:main/0`, not `main:main/0`. Emit a `main`
// shim forwarding to it so runners have a stable, well-known entry module to call.
//
// Not for fable-library: its compiled output is copied into every consuming app's
// fable_modules, so a `main` module of its own would collide with the app's.
if
entryModule <> "main"
&& not (Fable.Beam.Naming.isFableLibraryPath cliArgs.ProjectFile)
&& IO.File.Exists(IO.Path.Combine(srcDir, entryModule + ".erl"))
then
let mainShim =
$"""{generatedMarker}
-module(main).
-export([main/0, main/1]).

main() -> {entryModule}:main().

main(_Args) -> {entryModule}:main().
"""

writeIfChanged (IO.Path.Combine(srcDir, "main.erl")) mainShim

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

Expand Down Expand Up @@ -1303,6 +1381,11 @@ let private compilationCycle (state: State) (changes: ISet<string>) =
cliArgs
| _ -> state, cliArgs

if cliArgs.CompilerOptions.Language = Beam then
projCracked.SourceFiles
|> Array.map (fun f -> f.NormalizedFullPath)
|> checkBeamModuleNames cliArgs

let! fableCompiler =
match fableCompiler with
| None -> FableCompiler.Init(projCracked)
Expand Down Expand Up @@ -1462,7 +1545,14 @@ let private compilationCycle (state: State) (changes: ISet<string>) =

// Generate rebar3 scaffold for BEAM target after successful compilation
if cliArgs.CompilerOptions.Language = Beam && not hasError then
generateBeamScaffold cliArgs
// The last source file is the entry point of a Fable program: its module-level
// actions compile to that module's main/0.
let entryModule =
projCracked.SourceFiles
|> Array.last
|> fun f -> Pipeline.Beam.moduleName cliArgs f.NormalizedFullPath

generateBeamScaffold cliArgs entryModule

// Run process
let exitCode, state =
Expand Down
83 changes: 18 additions & 65 deletions src/Fable.Cli/Pipeline.fs
Original file line number Diff line number Diff line change
Expand Up @@ -503,74 +503,27 @@ module Rust =
}

module Beam =
/// Erlang module names must be lowercase snake_case and match the filename
let normalizeFileName path =
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)
let fileName = normalizeFileName targetPath
Path.Combine(targetDir, fileName + fileExt)

type BeamWriter(com: Compiler, cliArgs: CliArgs, pathResolver, targetPath: string) =
let sourcePath = com.CurrentFile
let fileExt = cliArgs.CompilerOptions.FileExtension
// Module naming lives in Fable.Transforms so the code generator and the CLI agree on the
// name of every generated module — an Erlang module's file name must match its `-module`
// atom, and an import must resolve to the same atom the imported file declared.
let normalizeAppName = Fable.Beam.Naming.normalizeAppName
let deriveDepAppName = Fable.Beam.Naming.deriveDepAppName
let extractDepVersion = Fable.Beam.Naming.extractDepVersion

/// The Erlang module name of an F# source file, qualified by the assembly it belongs to.
let moduleName (cliArgs: CliArgs) (sourcePath: string) =
Fable.Beam.Naming.erlangModuleName cliArgs.ProjectFile sourcePath

type BeamWriter(com: Compiler, targetPath: string) =
let stream = new IO.StreamWriter(targetPath)

interface Printer.Writer with
member _.Write(str) =
stream.WriteAsync(str) |> Async.AwaitTask

member _.MakeImportPath(path) =
let projDir = IO.Path.GetDirectoryName(cliArgs.ProjectFile)

let path =
Imports.getImportPath pathResolver sourcePath targetPath projDir cliArgs.OutDir path

if path.EndsWith(".fs", StringComparison.Ordinal) then
let path = Path.ChangeExtension(path, fileExt)
// Convert filename to snake_case to match Erlang module naming
let dir = Path.GetDirectoryName(path)
let fileName = normalizeFileName path + Path.GetExtension(path)
Path.Combine(dir, fileName)
else
path
// Erlang has no import paths: a module is referenced by its atom alone, and the
// Erlang printer never asks for one. Module names are resolved in Fable2Beam.
member _.MakeImportPath(path) = path

member _.AddSourceMapping(_, _, _, _, _, _) = ()

Expand All @@ -579,15 +532,15 @@ module Beam =

member _.Dispose() = stream.Dispose()

let compileFile (com: Compiler) (cliArgs: CliArgs) pathResolver isSilent (outPath: string) =
let compileFile (com: Compiler) isSilent (outPath: string) =
async {
let erlModule =
FSharp2Fable.Compiler.transformFile com
|> FableTransforms.transformFile com
|> Fable.Transforms.Beam.Compiler.transformFile com

if not (isSilent || ErlangPrinter.isEmpty erlModule) then
use writer = new BeamWriter(com, cliArgs, pathResolver, outPath)
use writer = new BeamWriter(com, outPath)
do! ErlangPrinter.run writer erlModule
}

Expand All @@ -599,4 +552,4 @@ let compileFile (com: Compiler) (cliArgs: CliArgs) pathResolver isSilent (outPat
| Php -> Php.compileFile com cliArgs pathResolver isSilent outPath
| Dart -> Dart.compileFile com cliArgs pathResolver isSilent outPath
| Rust -> Rust.compileFile com cliArgs pathResolver isSilent outPath
| Beam -> Beam.compileFile com cliArgs pathResolver isSilent outPath
| Beam -> Beam.compileFile com isSilent outPath
66 changes: 66 additions & 0 deletions src/Fable.Transforms/Beam/FABLE-BEAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,70 @@ Erlang modules implementing F# core types:
| `quicktest.fs` | `printfn "Hello from BEAM!"` | Done |
| `quicktest.fsproj` | Project file referencing Fable.Core | Done |

## Module Naming

Erlang's module namespace is **flat and global**. The atom in `-module(...)` is a module's only
identity: neither the directory the `.erl` file sits in nor the OTP application it belongs to
scopes it, and the code server resolves the atom across the whole code path. An `.erl` file must
also be named after the module it declares.

So every generated module name is qualified by the application it belongs to — the same
convention OTP itself follows (`cowboy_req`, `rebar_app_info`) and that Fable's own runtime
already used (`fable_list`, `fable_map`):

| F# source | Erlang module |
| ------------------------------------ | ----------------------- |
| `<proj>/Program.fs` in `MyApp` | `my_app_program` |
| `<proj>/Misc/Util2.fs` in `MyApp` | `my_app_misc_util2` |
| `../Scriptorium.Quill/DSL.fs` | `scriptorium_quill_dsl` |
| `fable_modules/Hedgehog.0.11/Gen.fs` | `hedgehog_gen` |

Naming a module after the bare basename of its file, as the backend used to, breaks in three ways
— all silent at compile time and fatal at runtime:

1. **OTP is shadowed.** `Gen.fs`, `Random.fs`, `String.fs`, `Timer.fs`, `Queue.fs`, ... all name
real OTP stdlib modules. OTP's win, and calls into the generated code raise `undef`.
2. **Assemblies overwrite each other.** Two `DSL.fs` files in two projects both emit `dsl.erl`
into the flat output `src/`; the one compiled last overwrites the other **on disk**, and the
loser's functions vanish from the output entirely.
3. **Files within an assembly collide** the same way (`Foo/Types.fs` vs `Bar/Types.fs`).

Two exemptions:

- **fable-library** keeps its bare, hand-maintained names (`fable_list`, `seq`, `range`, ...).
It is the one project whose *compiled* output ships as a dependency, and `getLibPath` in
`Transforms.Util` refers to its modules by exactly those names.
- **Native Erlang modules** reached through `BeamInterop` (`string`, `lists`, ...) are of course
referenced by their own names.

Naming lives in one place, `Fable.Beam.Naming.erlangModuleName` (`Beam/Prelude.fs`), because the
code generator (which must resolve an import to the atom the imported file declared) and the CLI
(which must write the file under the name of the module inside it) have to agree exactly.

Qualification is a convention, not a guarantee, so `checkBeamModuleNames` (`Fable.Cli/Main.fs`)
**fails the build** on the two ways it can still go wrong, rather than letting either surface as an
`undef` at runtime:

- two source files mapping to the same module name — it names both files;
- a module name that is one of OTP's own (`Naming.otpModules`). Qualification rules out the bare
names, but a two-segment name can still land on a real OTP module — an app named `Gen` with a
`Server.fs` produces `gen_server` — and fable-library's exempt modules are not qualified at all,
so a `Timer.fs` added to it would silently shadow OTP's `timer`.

### Entry point

Since module names are qualified, the entry point of a project compiled from `Program.fs` is
`my_app_program:main/0`, not `main:main/0`. Fable therefore also emits a small `src/main.erl`
shim exporting `main/0` and `main/1` that forwards to it, so runners have a stable, well-known
entry module:

```sh
erl -noshell -pa src -eval "main:main([])" -s init stop
```

As elsewhere in Fable's Beam output, the entry point is the *last* source file of the project:
its module-level actions compile to that module's `main/0`.

## Type Mappings

### Natural fits (F# → Erlang)
Expand Down Expand Up @@ -574,6 +638,8 @@ DecisionTree) were implemented in Phase 2. This phase adds records and structura
- [x] Import resolution and path handling
- [x] Export lists (`-export([...])`)
- [x] Snake_case output filenames (matching Erlang module name convention)
- [x] Module names qualified by their OTP app, so they can neither shadow an OTP module nor
collide across assemblies (see [Module Naming](#module-naming))
- [x] Function name sanitization (`$XXXX` hex sequences from F# backtick names)
- [x] Cross-module call resolution (derive module from `importInfo.Path`)
- [x] Inline `assertEqual`/`assertNotEqual` assertions (no util dependency needed)
Expand Down
2 changes: 1 addition & 1 deletion src/Fable.Transforms/Beam/Fable2Beam.Reflection.fs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ let rec private transformTypeInfoRec
if sourcePath = com.CurrentFile then
None // local call
else
Some(Fable.Beam.Naming.moduleNameFromFile sourcePath)
Some(Fable.Beam.Naming.erlangModuleName com.ProjectFile sourcePath)

let funcName =
FSharp2Fable.Helpers.getEntityDeclarationName com entRef |> reflectionFuncName
Expand Down
Loading
Loading