rebar3_erli18n_codegen (rebar3_erli18n v0.2.0)

Copy Markdown View Source

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

render_opts()

-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 generated catalog/0. Defaults to true (also the value when the key is ABSENT). false omits the nowarn and is used by the proof fixture. The precise -spec catalog() -> erli18n_server:compiled_spec(). is emitted either way.

render_spec()

-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

domain_sentinel()

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

generator_vsn()

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

module_name(Domain, Locale)

-spec module_name(binary(), erli18n_server:locale()) -> binary().

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.

module_sentinel()

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

quote_atom_source(Bin)

-spec quote_atom_source(binary()) -> iolist().

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.

render/2

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