Pure term-to-source emitter for compile-time erli18n catalogs.
render/2 lifts a render_spec() — a compiled_spec()-shaped 4-tuple whose
domain is a BINARY (not an atom): an already-parsed entry list plus an
already-compiled plural rule (or the fallback atom) and a baked_header() —
into the Erlang SOURCE of a tiny generated module whose single catalog/0
clause returns the equivalent compiled_spec() term as a literal (the domain
atom is interned by the COMPILER from the rendered source, never by an
atom-creating BIF on filesystem/CLI input). A build-time provider then compiles
the source to BEAM, so the consumer's boot registers the catalog through
erli18n_server:register_compiled_many/1 with NO runtime .po parse and NO
plural compile: every entry is pre-parsed and the plural AST is pre-built,
baked straight into the module's literal pool.
How the source is built
The catalog term is lifted to an abstract literal with erl_parse:abstract/2
and wrapped in hand-built module/export/marker/spec forms plus the catalog/0
function form; each form is rendered with erl_pp:form/1 under a @generated
banner. This uses ONLY erl_parse + erl_pp from stdlib — no merl, no
erl_syntax, no parse transform, and no compile:forms-to-BEAM step here
(compilation is a separate provider concern). The emitter is a pure function:
the same render_spec() and render_opts() always produce byte-identical
source.
The carrier's two dynamic atoms — its module name and its domain — are NOT
minted by an atom-creating BIF on the binary domain. Instead the forms are
rendered with two FIXED plugin-literal sentinel atoms (?MODULE_SENTINEL and
?DOMAIN_SENTINEL) standing in those slots, and a post-render binary:replace
swaps each rendered sentinel TOKEN for developer-controlled source text: the
raw binary module name, and quote_atom_source/1 of the binary domain. The
compiler — not the emitter — then interns the carrier's atoms from that bounded
source. The $ bytes force erl_pp to single-quote each sentinel into a token
that collides with no other rendered carrier token, so the replacement is
unambiguous and does not re-flow the erl_pp layout.
The -spec and eqwalizer
The generated catalog/0 ALWAYS carries the precise
-spec catalog() -> erli18n_server:compiled_spec().. By default (render_opts
eqwalizer_nowarn => true, also the value when the key is absent) it ALSO
carries a function-scoped -eqwalizer({nowarn_function, catalog/0}).: a
generated catalog can embed a deeply nested plural t:erli18n_plural:ast/0
literal that eqwalizer cannot always narrow to the spec, so the nowarn keeps a
generated module type-clean without weakening the precise spec. Passing
eqwalizer_nowarn => false omits the nowarn; it is used by the committed proof
fixture, whose compiled_spec() is simple enough to type-check against the
spec WITHOUT the escape hatch (so the spec itself is proven, not merely
asserted).
Marker attribute
Every generated module carries
-erli18n_compiled_catalog([{domain, _}, {locale, _}, {generator_vsn, _}]).
so a loader can discover compiled catalogs by attribute and detect schema
drift via generator_vsn/0.
Summary
Types
Options controlling render/2.
Input to render/2: a compiled_spec()-shaped 4-tuple whose domain is a
BINARY rather than an atom.
Functions
The fixed domain replace sentinel, '$erli18n_cc_domain$'.
The generator schema version stamped into the marker attribute.
Deterministic module name for the catalog of {Domain, Locale}.
The fixed module-name replace sentinel, '$erli18n_cc_module$'.
Render Bin as Erlang single-quoted atom SOURCE text.
Render a render_spec() to the source of its generated catalog module.
Types
-type render_opts() :: #{eqwalizer_nowarn => boolean()}.
Options controlling render/2.
eqwalizer_nowarn— whether to emit the function-scoped-eqwalizer({nowarn_function, catalog/0}).on the generatedcatalog/0. Defaults totrue(also the value when the key is ABSENT).falseomits the nowarn and is used by the proof fixture. The precise-spec catalog() -> erli18n_server:compiled_spec().is emitted either way.
-type render_spec() :: {binary(), erli18n_server:locale(), [erli18n_po:entry()], erli18n_server:baked_header()}.
Input to render/2: a compiled_spec()-shaped 4-tuple whose domain is a
BINARY rather than an atom.
Threading the domain as a binary end-to-end keeps render/2 from interning an
atom out of filesystem/CLI input. The carrier's domain atom is interned by the
COMPILER instead, from the quote_atom_source/1 text spliced over the rendered
?DOMAIN_SENTINEL token. The Locale, entry list and baked_header() are
identical to those of erli18n_server:compiled_spec().
Functions
-spec domain_sentinel() -> '$erli18n_cc_domain$'.
The fixed domain replace sentinel, '$erli18n_cc_domain$'.
Rendered into the carrier's domain slots (the marker attribute and the
catalog/0 literal) so a post-render binary:replace of its erl_pp token
can splice in quote_atom_source/1 of the real domain. Exposed so the replace
site and its tests share one source of truth for the sentinel atom.
-spec generator_vsn() -> binary().
The generator schema version stamped into the marker attribute.
A loader compares it against the version it expects so a module emitted by an
incompatible generator (different marker layout, catalog/0 contract or
literal shape) can be refused rather than mis-read.
Deterministic module name for the catalog of {Domain, Locale}.
Mangles Domain and Locale — both binaries — into the unique BINARY
erli18n_cc_<MangledDomain>__<MangledLocale>. Each non-[A-Za-z0-9] byte is
replaced by _ followed by its two lowercase hex digits, so the mapping is
INJECTIVE — distinct {Domain, Locale} pairs never collide. In particular a
pt_BR locale (pt_5fBR) and a pt-BR locale (pt_2dBR) yield distinct
module names. The __ double-underscore between the two mangled segments is
an unambiguous separator: a mangled segment never contains __ (its only _
bytes are escape markers, each followed by two hex digits).
The name is returned as a BINARY — no atom is interned here. The carrier's
module atom is interned by the compiler when the rendered source (with this
binary spliced over the ?MODULE_SENTINEL token) is compiled.
-spec module_sentinel() -> '$erli18n_cc_module$'.
The fixed module-name replace sentinel, '$erli18n_cc_module$'.
Rendered into the carrier's -module slot (and any other module-name slot) so
a post-render binary:replace of its erl_pp token can splice in the real
carrier module name. Exposed so the replace site and its tests share one
source of truth for the sentinel atom.
Render Bin as Erlang single-quoted atom SOURCE text.
Returns the iolist source of a quoted atom whose interned value equals
binary_to_atom(Bin, utf8): a leading ', each codepoint escaped via
escape_cp/1, and a trailing '. The atom is ALWAYS single-quoted (so
reserved words like if/end and metacharacter names like my-app scan back
correctly) and the output is ALWAYS pure 7-bit ASCII — every codepoint
>= 128, every control byte, DEL, the quote and the backslash are escaped,
so the source is safe to embed in any latin1-or-utf8 .erl file.
Bin MUST be valid UTF-8; a non-UTF-8 binary is a loud
error({invalid_domain_encoding, Bin}) (this function interns NO atom and so
cannot launder undecodable bytes into one). It performs byte/codepoint/integer
operations only — it constructs no atom itself; the carrier's atom is interned
by the COMPILER from this developer-controlled source text.
-spec render(render_spec(), render_opts()) -> {binary(), unicode:chardata()}.
Render a render_spec() to the source of its generated catalog module.
Returns {Module, Source} where Module is the deterministic module name as
a BINARY (see module_name/2) and Source is the complete .erl text as
unicode:chardata(): a @generated banner, then the module/export/marker
forms, the precise catalog/0 spec (with the optional eqwalizer nowarn per
Opts), and the catalog/0 function whose body is the equivalent
compiled_spec() lifted to an abstract literal. The result round-trips:
compiling the source and calling Module:catalog() yields the compiled_spec()
the render_spec() denotes (its binary domain interned to the matching atom).
The carrier is rendered through the unchanged forms/5 + erl_pp:form/1
pipeline with the two FIXED sentinel atoms in the module-name and domain slots;
a post-render binary:replace then splices the real binary module name and
quote_atom_source/1 of the domain over the rendered sentinel tokens, so the
carrier's dynamic atoms are interned by the compiler, not by any atom-creating
BIF here.
The function is pure and deterministic — identical Spec/Opts always
produce byte-identical Source.