A rebar3 plugin that extracts, merges, checks, and
reports gettext catalogs for the
erli18n runtime library. It is the Erlang
counterpart to mix gettext.extract / mix gettext.merge.
rebar3 erli18n extract # walk Erlang abstract forms into .pot templates
rebar3 erli18n merge # msgmerge-style sync of .po catalogs against the .pot
rebar3 erli18n check # CI gate: fail the build on .pot drift
rebar3 erli18n report # per-(Domain, Locale) translation completenessDistribution model — a SEPARATE package from erli18n
This plugin is published as its own Hex package, separate from the runtime
erli18n library, and it depends on that library ({deps, [{erli18n, "~> 0.5"}]}, {applications, [kernel, stdlib, erli18n]}). The dependency points
plugin → lib — the same direction as
rebar3_gpb_plugin → gpb,
rebar3_proper → proper, and rebar3_lint → elvis_core.
The split is forced by rebar3's design, not a preference:
- rebar3 has no auto-discovery. Mix loads any
Mix.Tasks.*BEAM shipped by any dependency with zero registration, so Gettext can ship its runtime and itsmix gettext.*tasks in one package. rebar3 surfaces a provider only when the consumer lists the plugin in{plugins, ...}and rebar3 calls itsinit/1. A tooling module merely bundled into theerli18nHex tarball could never be invoked downstream. - The runtime library must stay pure. The providers reuse the published PO
read/serialize API —
erli18n_po:parse/1,erli18n_po:dump/1, anderli18n_po:escape_string/1— across the package boundary. Bundling the providers intoerli18nwould drag rebar3-host-coupled modules (rebar_state,rebar_app_info,rebar_api, theproviderbehavior, all of which live inside the rebar3 escript and are not a fetchable Hex dependency) into a pure runtime library.
The clean reconciliation is a real {deps, [erli18n]} boundary in the
plugin → lib direction, never lib → plugin.
Installation
Add the plugin to your project's rebar.config. You also need erli18n
itself as a normal runtime dependency, since your code calls
erli18n:gettext/1 and friends:
{deps, [{erli18n, "~> 0.5"}]}.
{plugins, [rebar3_erli18n]}.The plugin registers four providers under the erli18n namespace, so the
commands are spelled rebar3 erli18n <provider>.
The catalog layout
The plugin uses the standard gettext on-disk layout, rooted at each app's
priv/gettext:
priv/gettext/<Domain>.pot # template (extract output)
priv/gettext/<Locale>/LC_MESSAGES/<Domain>.po # translated catalog--pot-dir overrides the priv/gettext root; --domain/-d restricts to a
single domain; --locale/-l selects a target locale for merge/report.
What gets extracted — the dynamic-msgid caveat
Extraction walks the abstract forms of your source (one epp pass per
file) and recognizes the full erli18n facade family by name and arity:
gettext/dgettext/dcgettext, ngettext/dngettext/dcngettext,
pgettext/dpgettext/dcpgettext, npgettext/dnpgettext/dcnpgettext,
plus the *f interpolation variants.
Only compile-time literals are extracted. A msgid, msgid_plural, or
msgctxt argument is collected only when it is a literal string/charlist (and,
for Domain, a literal atom). This matches the Gettext contract exactly:
%% Extracted — literal msgid:
erli18n:gettext(<<"Hello">>).
erli18n:ngettext(<<"1 file">>, <<"%{count} files">>, N).
erli18n:pgettext(<<"menu">>, <<"Save">>).
%% NOT extracted — the msgid is a runtime value, not a literal. This still
%% TRANSLATES correctly at runtime; it just cannot be discovered statically,
%% so you must ensure the msgid is present in the catalog by other means.
Key = choose_key(),
erli18n:gettext(Key).A call whose literal slot is not a compile-time literal is skipped, never
mis-keyed. This is the same limitation Gettext documents for *gettext_noop
and runtime-computed ids.
The merge contract
rebar3 erli18n merge syncs an existing .po against the freshly extracted
.pot, adopting the GNU msgmerge / mix gettext.merge semantics:
#:references and extracted comments are authoritative from the fresh.pot. The merge takes the up-to-date source locations from re-extraction.- Only
msgstr(the translation) is preserved from the old.po. For a msgid that still exists, its existing translation is carried over. - New msgids are fuzzy-matched against removed ones using Jaro similarity
(
rebar3_erli18n_jaro). A renamed string carries its old translation forward as a#, fuzzyentry with a#|previous-msgid hint, so a translator only has to confirm it. - A removed msgid that is not consumed as a fuzzy source is demoted to a
#~obsolete entry (its translation bytes preserved) rather than silently deleted.
The check gate (CI)
rebar3 erli18n check is the mix gettext --check-up-to-date experience for
Erlang: it re-extracts in memory and fails the build (non-zero exit) when a
committed .pot is out of date. By default it detects FULL drift — both
the msgid set and the #: references change is drift. Pass --names-only
for the laxer msgid-set-only comparison (stable against pure line churn) when a
team finds reference drift noisy. A missing .pot for a domain that has call
sites is itself drift.
Because only compile-time literals are extracted, a runtime-computed msgid can never produce a false drift failure in either mode — the dynamic-key guarantee.
The report completeness view
rebar3 erli18n report is a read-only view of per-(Domain, Locale)
translation completeness — distinct from the check gate. check answers
"is the committed .pot template in sync with the call sites?" and fails the
build on drift; report never fails the build. Instead it parses each
.po catalog and counts, for every domain and locale, how many entries are
translated versus missing. An entry counts as translated when its msgstr
(singular) is non-empty, or — for a plural entry — every plural form is
non-empty. The counts reflect what the runtime would actually serve:
#, fuzzy entries are dropped on load (per the runtime's parse contract), so
they are not counted as translated.
Run from a project with catalogs under priv/gettext:
$ cd examples/erli18n_demo
$ rebar3 erli18n report
erli18n translation report
==========================
domain: accounts
pt_BR 4/4 translated (0 missing)
domain: default
pt_BR 4/4 translated (0 missing)
domain: errors
pt_BR 4/4 translated (0 missing)
One block is printed per domain (every *.pot under the root, or just the one
named by --domain), and one line per locale (every locale directory found, or
just the one named by --locale). A locale with no catalog for a domain is
reported as (no catalog) rather than omitted.
Local development — the two-checkouts requirement
Until both packages are published, a consumer that wants to drive this plugin
against an in-repo erli18n must surface both apps through rebar3's native
_checkouts/ mechanism:
<consumer>/_checkouts/erli18n -> the runtime library app
<consumer>/_checkouts/rebar3_erli18n -> this plugin appBoth are load-bearing (empirically verified against rebar3 3.24 / OTP 28):
- The consumer's
{plugins, [rebar3_erli18n]}entry by name must still be present;_checkouts/rebar3_erli18nonly takes precedence over a Hex fetch for that name. - The plugin's own
{deps, [{erli18n, "~> 0.5"}]}declaration is what pulls the consumer's_checkouts/erli18nonto the plugin's runtime code path at provider-run time. Without the plugin declaring theerli18ndep, the consumer'serli18ncheckout does not land on the plugin path anderli18n_poisnon_existing(undef) when a provider runs. - Removing
_checkouts/erli18nmakes rebar3 resolveerli18nfrom Hex instead — so the checkout (not Hex) is what binds the unpublished lib in local dev.
rebar3 has no native {path, ...} dependency resource (that requires the
third-party rebar3_path_deps plugin), so _checkouts is the idiomatic way to
consume an unpublished in-repo plugin, per
rebar3.org/docs/configuration/dependencies.
After publish, the consumer drops the checkouts and resolves both packages from
Hex normally.
Proven cross-package load path
The plugin -> lib boundary is not asserted, it is executed and captured on
the real packages. The in-repo consumer examples/erli18n_demo/ surfaces both
apps through the two _checkouts above and drives the full pipeline. Each
provider reaches the runtime library through rebar3_erli18n_common, which
logs the loaded location of erli18n_po when the ERLI18N_DIAG_LOADPATH
environment variable is set:
$ cd examples/erli18n_demo
$ ERLI18N_DIAG_LOADPATH=1 rebar3 erli18n extract
===> erli18n: runtime lib erli18n_po loaded from \
.../examples/erli18n_demo/_build/default/checkouts/erli18n/ebin/erli18n_po.beam
===> erli18n: wrote .../priv/gettext/default.pot (4 entries)
...
$ ERLI18N_DIAG_LOADPATH=1 rebar3 erli18n merge --locale pt_BR # merges 3 catalogs
$ ERLI18N_DIAG_LOADPATH=1 rebar3 erli18n check # exit 0, catalogs up to date
At provider-run time code:which(erli18n_po) resolves under the consumer's
_build/<profile>/checkouts/erli18n/ebin/erli18n_po.beam — so the checkout,
not a Hex fetch, backs the erli18n_po:parse/1, erli18n_po:dump/1, and
erli18n_po:escape_string/1 calls that extract/merge/check/report
make across the published {deps, [erli18n]} boundary. rebar3 itself confirms
the resolution (App erli18n is a checkout dependency and cannot be locked),
there is no Fetching erli18n line, and no undef erli18n_po:dump/1. The
providers_SUITE runtime_lib_reachable_at_provider_run case and the
common_SUITE runtime_lib_path_resolves case assert the same edge in-node
(the lib is never non_existing and its API is callable).
Because that load path is proven, the providers reuse erli18n_po directly:
the plugin does not vendor a private copy of the escaper/dumper. A vendored
fallback was specified as a contingency only for the case where erli18n_po
could not be reached cross-package; that case did not occur, so no duplicate
escaper exists to drift out of sync with erli18n_po:escape_string/1.
Xref and the rebar3 host API
Every call into rebar3's own modules (providers, rebar_state, rebar_api,
rebar_app_info) is funneled through a single seam,
rebar3_erli18n_host.erl. Those modules are supplied by the rebar3 escript at
plugin-load time and are not a fetchable Hex dependency, so rebar3 xref
(which scans this app because apps/* is the umbrella's discovery root) reports
each of the eight host calls as an undefined function. The seam carries a
scoped -ignore_xref([...]) listing exactly those eight {M, F, A} edges (and
the seam's own wrappers), mirrored by an {xref_ignores, [...]} block in
rebar.config. Every other module stays under active
undefined_function_calls/undefined_functions checking, so the ignore is
load-bearing, not a blanket suppression.
Rejected alternatives:
- (a) declare
rebar3as a build dependency — impossible: there is norebar/rebar3Hex package carrying those host modules. - (b) extract the host beams out of the running escript onto an extra xref path — the old workaround; it was version-coupled to the local rebar3 and needed a generator escript plus a git-ignored beam directory. Removed in favor of the scoped ignore.
- (c) exclude the whole plugin app from xref — over-broad; it would silence real undefined-call bugs in the plugin's own logic modules.
License
Apache-2.0. See LICENSE.