Skip to content

feat(beam): add [<Beam.ModuleName>] to pin a generated module's name#4771

Open
dbrattli wants to merge 3 commits into
mainfrom
feat/beam-module-name-attribute
Open

feat(beam): add [<Beam.ModuleName>] to pin a generated module's name#4771
dbrattli wants to merge 3 commits into
mainfrom
feat/beam-module-name-attribute

Conversation

@dbrattli

@dbrattli dbrattli commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Rebased on main now 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.

[<Fable.Core.Beam.ModuleName("my_server")>]
module MyApp.Server

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 erlangModuleNameFor maps 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.

getOutPath takes a module-name resolver, because an .erl file must be named after the module it declares:

  • while compiling, the F# AST is right there, so we ask the compiler;
  • callers that merely predict an out path (watch's up-to-date check) look the name up in the map of pinned names from the last compilation, kept on 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 from transformFile) — not once per file that imports it.

Two consequences worth flagging

  • The duplicate-name check moved to after compilation. A pinned name only exists once the file has been type checked (Project.From starts 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.
  • --run now invokes main:main() instead of deriving the entry module from the predicted output path, which cannot see a pinned name. The scaffold always leaves a main module in src/ — 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 (main is at 2560). The pinned-name test calls the module by the literal atom it is supposed to declare:

let private pinnedValue () : int = emitErlExpr () "fable_tests_pinned:value()"

so a module named anything else is an undef at runtime rather than a silently passing test. The generated file is fable_tests_pinned.erl declaring -module(fable_tests_pinned), confirming both the transform and the CLI honour the attribute.

Also verified on a scratch project pinning my_server:

  • the output is my_server.erl, and a watch cycle triggered by an unrelated file recompiles only that file — the pinned module is not dragged in;
  • an invalid atom ("MyModule") fails with error FABLE: 'MyModule' is not a valid Erlang module name, reported once against the declaring file even though two other files import it;
  • forcing two files onto one pinned name fails the build naming both.

The JS quicktest still passes, since getOutPath is shared by every target.

🤖 Generated with Claude Code

Base automatically changed from feat/beam-module-naming to main July 13, 2026 11:59
dbrattli and others added 2 commits July 13, 2026 14:02
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
dbrattli force-pushed the feat/beam-module-name-attribute branch from 9980579 to 11af8eb Compare July 13, 2026 12:29
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>
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