fix(beam): qualify Erlang module names by their OTP app#4770
Merged
Conversation
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>
Collaborator
Author
|
Follow-up: the |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.erlfile lives in scopes it. The Beam backend named each module after the bare basename of its F# file (Gen.fs→gen.erl), which collides three ways — all silent at compile time and fatal at runtime:Gen.fs,Random.fs,String.fs(andTimer,Queue,Math,IO, …) emitgen.erl/random.erl/string.erl. OTP's modules win, so calls into the generated code raiseundef.DSL.fsfiles in two projects both emitsrc/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.Foo/Types.fsvsBar/Types.fs) collide the same way.Discovered compiling Scriptorium's
Scriptorium.Hedgehogsuite, which pulls in theHedgehogpackage (Gen.fs,Random.fs) alongside Quill's and Hedgehog'sDSL.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):fable_modules/Hedgehog.0.11/Gen.fsgenhedgehog_gen../Scriptorium.Quill/DSL.fsdslscriptorium_quill_dsl../Scriptorium.Hedgehog/DSL.fsdsl(clobbers)scriptorium_hedgehog_dsl<proj>/Misc/Util2.fsutil2my_app_misc_util2Naming 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, sinceresolveImportModuleNameonly 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, andgetLibPathrefers to its modules by exactly those names — and native Erlang modules reached viaBeamInteropare untouched.Guards
Qualification is a convention, not a guarantee, so
checkBeamModuleNamesfails the build on the two ways a name can still go wrong — instead of letting either surface as anundefat runtime:Naming.otpModules, the module lists oferts+kernel+stdlib). Qualification rules out the bare names, but a two-segment name can still land on a real OTP module — an app namedGenwith aServer.fsproducesgen_server— and fable-library's exempt modules aren't qualified at all, so aTimer.fsadded to it would silently shadow OTP'stimer.Breaking
This renames every generated module, so the entry point of a project is no longer
main. Fable now emits asrc/main.erlshim exportingmain/0andmain/1that forwards to the real entry module, so runners callingerl -eval "main:main([])"keep working unchanged.Existing output directories are not pruned: a rebuild in place leaves the old bare-named
.erlfiles (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
gen.erl/random.erl/string.erl(all real OTP stdlib modules, verified withcode:which/1) and a singletypes.erlexporting onlyshape_reflection/0, area/1— the secondTypes.fssilently absent from the output.ModuleNamingTests.fsthat call across file boundaries intoNaming/Gen.fs,Naming/Random.fs,Naming/String.fsand two same-namedTypes.fsfiles. The same run also shows the OTP guard doesn't over-fire across the test project, its dependencies, or fable-library.GencontainingServer.fs:'gen_server' from Gen/Server.fs.fable-libraryrebuild still emitsseq/range/fable_listunchanged.quicktest beamruns both via--runand viamain:main([])/main:main().The test runner's
*_testsname filter no longer discriminates (every module now carries the app prefix), so it selects test modules by whether they exporttest_*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 alongsideRootModuleinFable.Transforms/State.fs, which is shared by every target.🤖 Generated with Claude Code