Skip to content

fix(beam): qualify Erlang module names by their OTP app#4770

Merged
dbrattli merged 2 commits into
mainfrom
feat/beam-module-naming
Jul 13, 2026
Merged

fix(beam): qualify Erlang module names by their OTP app#4770
dbrattli merged 2 commits into
mainfrom
feat/beam-module-naming

Conversation

@dbrattli

@dbrattli dbrattli commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

Erlang's module namespace is flat and global: the atom in -module(...) is a module's only identity, and neither the directory nor the OTP application an .erl file lives in scopes it. The Beam backend named each module after the bare basename of its F# file (Gen.fsgen.erl), which collides three ways — all silent at compile time and fatal at runtime:

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

Discovered compiling Scriptorium's Scriptorium.Hedgehog suite, which pulls in the Hedgehog package (Gen.fs, Random.fs) alongside Quill's and Hedgehog's DSL.fs.

Fix

Qualify every generated module name with the application it belongs to — the convention OTP itself follows (cowboy_req, rebar_app_info) and that Fable's own runtime already used (fable_list):

F# source before after
fable_modules/Hedgehog.0.11/Gen.fs gen hedgehog_gen
../Scriptorium.Quill/DSL.fs dsl scriptorium_quill_dsl
../Scriptorium.Hedgehog/DSL.fs dsl (clobbers) scriptorium_hedgehog_dsl
<proj>/Misc/Util2.fs util2 my_app_misc_util2

Naming lives in one place, Fable.Beam.Naming.erlangModuleName (Beam/Prelude.fs), because the code generator (which resolves an import to the atom the imported file declared) and the CLI (which writes the file under the name of the module inside it) must agree exactly. It is a pure function of the path, since resolveImportModuleName only ever sees a path.

Two exemptions: fable-library keeps its bare, hand-maintained names (fable_list, seq, …) — it is the one project whose compiled output ships as a dependency, and getLibPath refers to its modules by exactly those names — and native Erlang modules reached via BeamInterop are untouched.

Guards

Qualification is a convention, not a guarantee, so checkBeamModuleNames fails the build on the two ways a name can still go wrong — instead of letting either surface as an undef at runtime:

  • Duplicate module names, naming both files, instead of silently overwriting one.
  • Names that shadow OTP's own (Naming.otpModules, the module lists of erts + kernel + stdlib). 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 aren't qualified at all, so a Timer.fs added to it would silently shadow OTP's timer.

Breaking

This renames every generated module, so the entry point of a project is no longer main. Fable now emits a src/main.erl shim exporting main/0 and main/1 that forwards to the real entry module, so runners calling erl -eval "main:main([])" keep working unchanged.

Existing output directories are not pruned: a rebuild in place leaves the old bare-named .erl files (gen.erl, …) next to the new qualified ones, and rebar3 compiles both — so the shadowing persists until the output directory is cleaned. Delete it once when upgrading.

Verification

  • Confirmed these are genuine regression tests by rebuilding the old compiler against them: it emitted gen.erl / random.erl / string.erl (all real OTP stdlib modules, verified with code:which/1) and a single types.erl exporting only shape_reflection/0, area/1 — the second Types.fs silently absent from the output.
  • 2539 Beam tests pass, including four new ones in ModuleNamingTests.fs that call across file boundaries into Naming/Gen.fs, Naming/Random.fs, Naming/String.fs and two same-named Types.fs files. The same run also shows the OTP guard doesn't over-fire across the test project, its dependencies, or fable-library.
  • Duplicate-name error verified by forcing a clash; it names both files.
  • OTP-shadowing error verified by compiling a project named Gen containing Server.fs: 'gen_server' from Gen/Server.fs.
  • Forced fable-library rebuild still emits seq / range / fable_list unchanged.
  • quicktest beam runs both via --run and via main:main([]) / main:main().

The test runner's *_tests name filter no longer discriminates (every module now carries the app prefix), so it selects test modules by whether they export test_* functions instead.

Follow-up

The [<Erlang.ModuleName("...")>] escape hatch — needed for anything implementing an OTP behaviour, where the module name is part of the contract — is not in this PR. An importing file only ever sees the imported file's path, so a per-file attribute override has to be precomputed into a path→name map alongside RootModule in Fable.Transforms/State.fs, which is shared by every target.

🤖 Generated with Claude Code

Erlang's module namespace is flat and global: the atom in `-module(...)` is
a module's only identity, and neither the directory nor the OTP application
an .erl file lives in scopes it. Naming each module after the bare basename
of its F# file therefore collided three ways, all silent at compile time and
fatal at runtime:

- Gen.fs, Random.fs, String.fs (and Timer, Queue, Math, IO, ...) emitted
  gen.erl / random.erl / string.erl, which are OTP stdlib modules. OTP's win,
  so calls into the generated code raised `undef`.
- Two DSL.fs files in two assemblies both emitted src/dsl.erl. The second
  overwrote the first on disk and its functions vanished from the output.
- Same-named files in one assembly (Foo/Types.fs, Bar/Types.fs) collided the
  same way.

Module names are now qualified by the application they belong to, as OTP
itself does (cowboy_req, rebar_app_info) and as Fable's runtime already did
(fable_list): Hedgehog/Gen.fs -> hedgehog_gen, Quill/DSL.fs ->
scriptorium_quill_dsl. fable-library keeps its bare names, which getLibPath
refers to, and native Erlang modules are untouched.

Should two files still map to one name, Fable now fails with an error naming
both instead of silently overwriting one.

This renames every generated module, so the entry point of a project is no
longer `main`. Fable emits a src/main.erl shim forwarding to it, so runners
calling `main:main([])` keep working.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli

Copy link
Copy Markdown
Collaborator Author

Follow-up: the [<Beam.ModuleName>] escape hatch is now up as #4771, stacked on this branch. It turned out not to need the State.fs plumbing I expected — GetRootModule + TryGetEntity already give the transform a path → entity → attribute route.

Follow-up hardening on the app-qualified module naming:

- Route the import path check through Naming.isFSharpSource. The guard in
  resolveImportModuleName tested `EndsWith ".fs"`, which is false for ".fsx",
  so an .fsx import was named as if it were a native Erlang module while the
  CLI wrote it out app-qualified — the exact compiler/CLI disagreement the
  shared naming function exists to prevent.

- Fail the build on a generated module named after one of OTP's erts, kernel
  or stdlib modules. Qualification rules out the bare names (`gen`, `string`),
  but a two-segment name can still land on `gen_server` or `erl_eval`, and
  fable-library's exempt modules are not qualified at all.

- Qualify by project and file name when the source file and the project file
  share no ancestor at all (different Windows drives). "Below the shared
  ancestor" was then the whole absolute path, baking the machine's directory
  layout into the module name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli
dbrattli merged commit 5516c8d into main Jul 13, 2026
31 checks passed
@dbrattli
dbrattli deleted the feat/beam-module-naming branch July 13, 2026 11:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant