feat(beam): add [<Beam.ModuleName>] to pin a generated module's name#4771
Open
dbrattli wants to merge 3 commits into
Open
feat(beam): add [<Beam.ModuleName>] to pin a generated module's name#4771dbrattli wants to merge 3 commits into
dbrattli wants to merge 3 commits into
Conversation
Module names are derived from the file's path and qualified by its app, which
is right for ordinary code. But a module implementing an OTP behaviour, or one
called from hand-written Erlang, has its name as part of its contract and needs
to say so:
[<Fable.Core.Beam.ModuleName("my_server")>]
module MyApp.Server
The name must be a plain unquoted Erlang atom, and must not collide with another
module — both are compile errors.
Resolving this is the one place the naming scheme needs more than a path: an
import carries the imported file's path, never its entity, so erlangModuleNameFor
maps path -> root module (GetRootModule) -> entity (TryGetEntity) -> attribute.
getOutPath now takes the compiler when there is one, so the output file is named
after the pinned atom; callers that merely predict an out path pass None.
The duplicate-name check moves to after compilation, since a pinned name only
exists once the file has been type checked, and checking derived names before
would reject a clash that a pinned name resolves. `--run` now invokes main:main()
rather than deriving the entry module from the output path, which cannot see a
pinned name; the scaffold always leaves a `main` module in src/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups on [<Beam.ModuleName>]: - Watch mode predicted the out path of a pinned file from its *derived* name, never found that file on disk, and so treated the file as deleted — recompiling it on every cycle, forever. The CLI now remembers the pinned names of the last compilation (State.BeamPinnedModuleNames) and predicts with those; getOutPath takes a name resolver rather than a compiler, since a pinned name lives in the F# AST and only the compile path has one. - Report an invalid pinned name once, from the file that declares it, rather than once per file that imports it (and again from getOutPath): validation is split out of erlangModuleNameFor into checkPinnedModuleName, called by transformFile. - Apply the same canPinModuleName rule in the CLI as in the code generator, so a fable-library file could not be pinned by one and derived by the other. - Check a pinned name against the OTP module set like any other: pinning is for naming a module its contract requires, not for taking a name OTP has. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dbrattli
force-pushed
the
feat/beam-module-name-attribute
branch
from
July 13, 2026 12:29
9980579 to
11af8eb
Compare
getOutPath serves every target, so a Beam-specific explanation does not belong on its signature. Put the reasoning where the parameter is used. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dbrattli
added a commit
to fable-compiler/Fable.Beam
that referenced
this pull request
Jul 18, 2026
…riants (#128) * feat(beam): add BeamChardata type and raw chardata string variants Fable.Beam is increasingly used to author OTP code, not just call into it (cf. fable-compiler/Fable#4771, pinning a module's Erlang name for OTP behaviours). In that world the chardata the OTP string/uri_string modules return is a feature, not an impurity: it is valid anywhere a binary or iodata is accepted (io:format, gen_tcp:send, Cowboy bodies), and keeping intermediate results unflattened is the idiomatic way to build output and flatten once at the I/O boundary. Add BeamChardata (erased unicode:chardata(), in the BeamList/BeamMap family) with ofString/toString, and a `*Raw` variant of every string function that returns chardata: reverseRaw, padRaw, padDirRaw, padWithRaw, replaceFirstRaw, replaceAllRaw, composeQueryRaw. The default string-returning functions are unchanged; the Raw variants skip the unicode:characters_to_binary flatten and hand back BeamChardata. This supersedes the commented-out ImportAll stubs left in String.fs: reverse and pad returned chardata and so never could be ImportAll members, but they are fully bound now (string form + raw form), so the stubs become one-line pointers to the working functions. Not BeamList<char>, as first sketched: only reverse yields a charlist; pad and replace yield iodata (nested binaries and integers), which BeamList<char> would misrepresent. One honest chardata type covers all of them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(bindings): document the dual-API (F#-friendly default + *Raw) convention Write down the convention BeamChardata introduces: when an OTP function returns a BEAM-native form (chardata, or a raw Erlang list) that is efficient for authoring Erlang but awkward in F#, bind it twice — a flattened/ref-wrapped default under the plain name, and a `*Raw` variant returning the native type (BeamChardata / BeamList<'T>). Includes a "where it applies" table: the chardata axis is done (string:reverse/ pad/replace, uri_string:compose_query); the raw-list axis (maps:keys/values/ to_list, string/binary/re:split, proplists:get_keys) and io_lib:format are identified candidates, not yet implemented. Also cross-referenced from the Quick Reference, the F#->Erlang type table, and the type-choice decision tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(beam): apply the dual-API convention to raw lists and add io_lib Implements the BINDINGS-GUIDE "Dual API" candidates surfaced in the survey. io_lib (new module IoLib.fs): io_lib:format is the canonical build-a-string- from-a-template function and returns chardata. Bound as `format` (-> string, flattened) + `formatRaw` (-> BeamChardata), mirroring the string module. Raw-list variants: every binding that ref-wraps a native Erlang list into an F# array now also offers a `*Raw` returning the native BeamList<'T>, for BEAM-side use without the new_ref round-trip: maps: keysRaw, valuesRaw, toListRaw string: splitFirstRaw, splitAllRaw binary: splitFirstRaw, splitAllRaw re: splitRaw, splitWithRaw, splitMPRaw, splitPartsRaw proplists: getKeysRaw The default array-returning members are unchanged. BeamList lives in Lists.fs, which the fsproj compiled *after* Maps.fs, so Maps could not reference it. Lists and Maps are mutually independent (no circular use), so Lists.fs now compiles first; Maps.fs opens Fable.Beam.Lists. String.fs and Re.fs likewise gain the open. +13 tests (380 total): raw lists assert the native form matches the array member (length / structural equality), and io_lib asserts the format/formatRaw round-trip. Guide's "where it applies" table updated to reflect what's done. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: retire BROKEN-BINDINGS.md; fold its durable lesson into the guide Every bug BROKEN-BINDINGS.md documented is fixed and merged, so a file named "broken bindings" that lists nothing broken is stale and misleading. The forensic account stays in git history and the #123 writeup. Its one reusable lesson not already in the guide — assert the invariant a function guarantees, not an implementation-defined value that varies by OTP release (the binary:referenced_byte_size 5-on-25 / 40-on-27 case) — is now an anti-pattern in BINDINGS-GUIDE.md. The chardata bug class it described is already covered by the Dual-API section; that section no longer links the deleted file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(string): correct the pad/reverse stub comments The old comments claimed these "can't be ImportAll members" because they return chardata. That is only true for a `string` return (ImportAll codegen can't insert the unicode:characters_to_binary flatten). A BeamChardata-typed ImportAll member needs no conversion and would compile fine. State the real reason: the string form needs a flatten, so it is a module function; the raw form is exposed as `*Raw` to keep raw variants uniform across the library, not because an interface member is impossible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: RFC for reducing per-module API surface count (follow-up) Captures the concern raised in #128 review: each binding module exposes up to three surfaces — the ImportAll interface (xxx.*, tupled), module helpers (curried), and the *Raw variants. The *Raw layer is justified; the wart is the arbitrary interface-vs-module split, driven by whether a return needs an Emit wrapper rather than by anything a caller sees. Repo-wide, predates the dual-API work. Records options (unify onto curried module functions / onto the interface / document only), recommends the curried-module direction prototyped on String first, and notes the breaking, repo-wide impact (Cowboy + synapse). Hand-off for a follow-up session; no code changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- 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.
Rebased on
mainnow that #4770 has merged.Why
#4770 derives every Erlang module name from the file's path and qualifies it with its app, which is right for ordinary code. But a module implementing an OTP behaviour, or one called from hand-written Erlang, has its name as part of its contract and needs to be able to say so. That PR listed this as follow-up; this is it.
The attribute must be fully qualified — it precedes the module declaration, so it cannot rely on an
open. The name has to be a plain unquoted Erlang atom; it must not collide with another module; and it must not be one of OTP's own — pinning is for naming a module its contract requires, not for taking a name OTP already has. All three are compile errors.How it resolves
This is the one place the naming scheme needs more than a path: an import carries the imported file's path, never its entity. So
erlangModuleNameFormaps path → root module (GetRootModule) → entity (TryGetEntity) → attribute. Both already exist on the transforms'Compiler, so no new compiler state is needed — the plumbing I originally feared in #4770 turned out to be unnecessary.getOutPathtakes a module-name resolver, because an.erlfile must be named after the module it declares:State.BeamPinnedModuleNames. Without that memory they would predict the derived name, never find that file on disk, conclude the file had been deleted, and recompile every pinned file on every watch cycle, forever.An invalid pinned name is reported once, by the file that declares it (
checkPinnedModuleName, called fromtransformFile) — not once per file that imports it.Two consequences worth flagging
Project.Fromstarts with an empty implementation-file list), and checking derived names before would reject a clash that a pinned name is there to resolve. Output may be written before the build fails, but it still fails loudly and names both files.--runnow invokesmain:main()instead of deriving the entry module from the predicted output path, which cannot see a pinned name. The scaffold always leaves amainmodule insrc/— either the entry module itself, or the shim from fix(beam): qualify Erlang module names by their OTP app #4770.Verification
2562 Beam tests pass, 2 of them new (
mainis at 2560). The pinned-name test calls the module by the literal atom it is supposed to declare:so a module named anything else is an
undefat runtime rather than a silently passing test. The generated file isfable_tests_pinned.erldeclaring-module(fable_tests_pinned), confirming both the transform and the CLI honour the attribute.Also verified on a scratch project pinning
my_server:my_server.erl, and a watch cycle triggered by an unrelated file recompiles only that file — the pinned module is not dragged in;"MyModule") fails witherror FABLE: 'MyModule' is not a valid Erlang module name, reported once against the declaring file even though two other files import it;The JS quicktest still passes, since
getOutPathis shared by every target.🤖 Generated with Claude Code