All notable changes to rebar3_erli18n will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning 2.0.0.
Versioning policy
Per SemVer 2.0.0 §4, this project is in the
0.x.y initial-development phase. The plugin's CLI surface (rebar3 erli18n {extract,merge,check,report}, their flags, and the on-disk catalog layout) may
change in a 0.x minor with a CHANGELOG note; additive flags and providers are
the norm.
Unreleased
0.1.0 — 2026-06-23
Initial release of the rebar3_erli18n catalog-tooling plugin as its own Hex
package. It depends on the runtime library erli18n ({deps, [{erli18n, "~> 0.5"}]}) and is published after erli18n 0.5.0. The package is built and
published from its own app directory (cd apps/rebar3_erli18n && rebar3 hex publish ...), which resolves erli18n from Hex into a per-app lock as a
level-0 {pkg,...} entry — the entry create_package carries into the
published requirements (verified locally: the per-app build produces tarball
requirements = {erli18n, "~> 0.5"}). That resolution can only happen once the
matching erli18n minor is live on Hex, which is why the publish order is
erli18n first, then the plugin.
Added
- Initial
rebar3_erli18nplugin package. Promoted from in-repo tooling to a first-class, publish-ready rebar3 plugin app underapps/rebar3_erli18n/in the erli18n umbrella. Ships four providers under theerli18nnamespace —extract,merge,check, andreport— plus the host seam (rebar3_erli18n_host), the abstract-form extractor, the Jaro fuzzy matcher, the keyword spec, and the PO metadata serializer. - README documenting the opt-in
{plugins, [rebar3_erli18n]}install, the Gettext-style merge contract (#:references and extracted comments authoritative from the fresh.pot; onlymsgstrpreserved from the old.po; new msgids fuzzy-matched against removed ones into#, fuzzyentries with a#|previous-msgid hint; removed msgids demoted to#~obsolete), the dynamic-msgid caveat (only compile-time literal msgids are extracted; a runtime-computed id still translates but is not statically discoverable), the consumer two-checkouts requirement for local dev, and the rejected xref-alternatives note. - Apache-2.0 LICENSE.
- Executed proof of the plugin → lib load path. Added a
ERLI18N_DIAG_LOADPATH-gated diagnostic inrebar3_erli18n_commonthat logs the loaded location oferli18n_poat provider-run time. Driven fromexamples/erli18n_demo/,extract→merge --locale pt_BR→checkall succeed andcode:which(erli18n_po)resolves under the consumer's_build/<profile>/checkouts/erli18n/ebin/erli18n_po.beam— proving the unpublished runtime library is reached through the consumer's checkout (not a Hex fetch) across the{deps, [erli18n]}boundary, with noundef erli18n_po:dump/1. Theproviders_SUITEruntime_lib_reachable_at_provider_runandcommon_SUITEruntime_lib_path_resolvescases assert the same edge in-node. See the README "Proven cross-package load path" section. Because the path is proven, the contingency private escaper/dumper was not vendored — the providers reuseerli18n_po:escape_string/1directly.
Changed
- Declared a real dependency on the runtime library
(
{deps, [{erli18n, "~> 0.5"}]},{applications, [kernel, stdlib, erli18n]}), replacing the earlier false "build-only, kernel + stdlib, no runtime erli18n dep" claim. The providers reuse the published PO API (erli18n_po:parse/1,erli18n_po:dump/1,erli18n_po:escape_string/1) across this package boundary, in the plugin → lib direction (the same asrebar3_gpb_plugin→gpb). This dependency is also what binds an unpublished consumer's_checkouts/erli18nonto the plugin's runtime path at provider-run time. - Form walk is now O(nodes). The abstract-form walk called
lists:flattenat every recursion level (O(extractions × ast-depth)); it now threads a single accumulator and reverses once at the top. Behavior is identical. - Keyword spec is a compile-time constant.
rebar3_erli18n_keywords:spec/0built the ~48-entry{Name, Arity} => slots()table withmaps:merge/2on every call (andlookup/2calls it per look-up). It is now a single literal map, so the compiler builds it once and every call returns the same shared constant;lookup/2is a singlemaps:findover that constant. The table contents are unchanged.
Fixed
merge'sprevious_of/1now renders in the generated docs. The white-box-only export carried its rationale only in a plain%%comment, which ex_doc does not read, so the function surfaced on the published doc page as undocumented. Its explanation is now a real-docattribute (a native EEP-48 Docs chunk), stating that it is a build-tool internal exported solely for white-box testing and not part of any published (Hex) API surface. No behavior change.checknow detects a domain whose call sites have all vanished. The freshness check folded only over the freshly-extracted domains, so a domain whose every call site was deleted dropped out of extraction entirely and its now-orphaned committed.potwas never compared — drift was missed andcheckwrongly passed.checknow compares the union of the freshly-extracted domains and the domains with a committed<Domain>.poton disk; a domain present on disk but absent from fresh extraction is compared against an empty catalog, so its stale.potcorrectly reports drift (it should be regenerated to empty or removed). The dynamic-key guarantee is unaffected — a legitimately dynamic key is never extracted, so it never appears in a committed.potand never produces a phantom domain.- Extractor no longer crashes on a surrogate-code-point binary msgid. A
literal binary msgid whose integer segment is a UTF-16 surrogate
(
16#D800..16#DFFF, e.g.erli18n:gettext(<<16#D800>>)) passed the integer-segment guard but then failed to encode as<<Int/utf8>>, raisingbadargand aborting the wholeextract/check/merge/reportrun on a stacktrace. The integer-segment guard now excludes the surrogate range, so such a segment is non-resolvable and the call site is skipped exactly like any other non-compile-time-literal msgid (the documented dynamic-key-skip contract), never crashing.
Removed
- The host-beam extraction workaround (a vendored generator escript that
extracted the rebar3 host modules into a generated beam directory, plus the
matching root
rebar.configproject-app-dirs / extra-paths wiring that analyzed the plugin as a project app). The rebar3 host modules (providers,rebar_state,rebar_api,rebar_app_info) are now resolved for xref by a scoped-ignore_xref([...])in therebar3_erli18n_hostseam and a matching{xref_ignores, [...]}inrebar.config, confined to the eight host{M, F, A}edges — every other module stays under activeundefined_function_callschecking.
Tests
report's console output is now asserted, not just{ok, _}. The fourreport_*provider cases previously asserted only thatdo/1returned{ok, _}, never inspecting the printed table — so a format regression would pass silently. They now capture the real per-(Domain, Locale)text the command prints (by swapping the test process's group leader for a capturing I/O server, exercisingdo/1->rebar3_erli18n_host:console/2, not a private builder) and assert it byte-for-byte, including the(no catalog)line, an explicit---domainreport, and a fully-translated plural counting as1/1.- Adversarial
.pocoverage formerge/check/report. Beyond the lone truncated-msgstrparse error, three committed fixtures underproviders_SUITE_data/now drive the documented fail-soft behavior: an invalid-UTF-8 body (raw0xFF 0xFEunder acharset=UTF-8header) makesmergeandreportreturn a structured{error, _}naming the file and thecharset_conversionreason — and makescheckreport drift in both the default and--names-onlymodes — never a crash; a line-wrapped old msgid ("Sign in " "to your account") is decoded to the same key as the unwrapped fresh.potmsgid, so its translation carries over with no fuzzy and no obsolete (pinning the wrapping-insensitive equality contract); and a larger 60-entry old.poexercises theread_oldparse path at scale, carrying the one surviving key and demoting the other 59 to#~obsolete.