rebar3_erli18n

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 completeness

Distribution 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_plugingpb, rebar3_properproper, and rebar3_lintelvis_core.

The split is forced by rebar3's design, not a preference:

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:

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 app

Both are load-bearing (empirically verified against rebar3 3.24 / OTP 28):

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_SUITEruntime_lib_reachable_at_provider_run case and the common_SUITEruntime_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:

License

Apache-2.0. See LICENSE.