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.1 — 2026-06-25
Changed
- Raised the
erli18ndependency from~> 0.5to~> 0.6, in lockstep with the co-releasederli18n0.6.0. The plugin still calls only the long-stableerli18n_po:parse/1/dump/1/escape_string/1API, so the bump pins the exact library line this release is built and tested against rather than requiring new API. The publish order is unchanged —erli18n0.6.0 must be live on Hex before this plugin, since the publishedrequirementsresolve~> 0.6.
Fixed
extractandmergeno longer crash with abadmatchwhen a catalog file cannot be written. Both providers matchedfile:write_file/2(andfilelib:ensure_path/1/ensure_dir/1) against a bareok =, so any filesystem failure — a read-onlypriv/gettext, an uncreatable parent,enospc— aborted the wholeextract/mergerun on a{badmatch, {error, _}}stacktrace.extract'swrite_pots/write_each_potandmerge'swrite_po/2now short-circuit to{error, {write_failed, Path, Reason}}on the first failure, whichdo/1surfaces as a normal{error, _}provider result. No CLI, flag, or on-disk-layout change.rebar3_erli18n_common:format_error/1renders the new{write_failed, Path, Reason}reason aserli18n: cannot write <path>: <reason>, so a write failure prints a clean human-readable message instead of the raw~pcatch-all.
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.