# Changelog
All notable changes to `rebar3_erli18n` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
## Versioning policy
Per [SemVer 2.0.0 §4](https://semver.org/#spec-item-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.2] — 2026-06-28
Documentation and packaging patch. No code or behavior change — the plugin's
CLI and its `{erli18n, "~> 0.6"}` requirement are unchanged.
### Changed
- **Refreshed the package documentation and Hex metadata.** The module doc now
states the current `erli18n ~> 0.6` line, the README is brought in step with
the umbrella's OTP 27/28/29 gate, and the `.app.src` links point at the
current source and docs.
## [0.1.1] — 2026-06-25
### Changed
- **Raised the `erli18n` dependency from `~> 0.5` to `~> 0.6`,** in lockstep with
the co-released `erli18n` 0.6.0. The plugin still calls only the long-stable
`erli18n_po:parse/1` / `dump/1` / `escape_string/1` API, 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 — `erli18n` 0.6.0 must be
live on Hex before this plugin, since the published `requirements` resolve
`~> 0.6`.
### Fixed
- **`extract` and `merge` no longer crash with a `badmatch` when a catalog file
cannot be written.** Both providers matched `file:write_file/2` (and
`filelib:ensure_path/1` / `ensure_dir/1`) against a bare `ok =`, so any
filesystem failure — a read-only `priv/gettext`, an uncreatable parent,
`enospc` — aborted the whole `extract`/`merge` run on a `{badmatch, {error, _}}`
stacktrace. `extract`'s `write_pots`/`write_each_pot` and `merge`'s `write_po/2`
now short-circuit to `{error, {write_failed, Path, Reason}}` on the first
failure, which `do/1` surfaces as a normal `{error, _}` provider result. No CLI,
flag, or on-disk-layout change.
- **`rebar3_erli18n_common:format_error/1` renders the new `{write_failed, Path,
Reason}` reason** as `erli18n: cannot write <path>: <reason>`, so a write
failure prints a clean human-readable message instead of the raw `~p`
catch-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_erli18n` plugin package.** Promoted from in-repo tooling to
a first-class, publish-ready rebar3 plugin app under `apps/rebar3_erli18n/`
in the erli18n umbrella. Ships four providers under the `erli18n` namespace —
`extract`, `merge`, `check`, and `report` — 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`; only `msgstr` preserved from the old
`.po`; new msgids fuzzy-matched against removed ones into `#, fuzzy` entries
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 in `rebar3_erli18n_common` that logs
the loaded location of `erli18n_po` at provider-run time. Driven from
`examples/erli18n_demo/`, `extract` → `merge --locale pt_BR` → `check` all
succeed and `code: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 no
`undef erli18n_po:dump/1`. The `providers_SUITE`
`runtime_lib_reachable_at_provider_run` and `common_SUITE`
`runtime_lib_path_resolves` cases 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 reuse
`erli18n_po:escape_string/1` directly.
### 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 as
`rebar3_gpb_plugin` → `gpb`). This dependency is also what binds an
unpublished consumer's `_checkouts/erli18n` onto the plugin's runtime path at
provider-run time.
- **Form walk is now O(nodes).** The abstract-form walk called `lists:flatten`
at 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/0`
built the ~48-entry `{Name, Arity} => slots()` table with `maps:merge/2` on
**every** call (and `lookup/2` calls 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/2` is a single `maps:find` over that constant. The
table contents are unchanged.
### Fixed
- **`merge`'s `previous_of/1` now 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 `-doc` attribute (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.
- **`check` now 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 `.pot` was never compared — drift was missed and
`check` wrongly passed. `check` now compares the **union** of the
freshly-extracted domains and the domains with a committed `<Domain>.pot` on
disk; a domain present on disk but absent from fresh extraction is compared
against an empty catalog, so its stale `.pot` correctly 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 `.pot` and 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>>`, raising
`badarg` and aborting the whole `extract`/`check`/`merge`/`report` run 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.config` project-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 the `rebar3_erli18n_host` seam and a matching
`{xref_ignores, [...]}` in `rebar.config`, confined to the eight host
`{M, F, A}` edges — every other module stays under active
`undefined_function_calls` checking.
### Tests
- **`report`'s console output is now asserted, not just `{ok, _}`.** The four
`report_*` provider cases previously asserted only that `do/1` returned
`{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, exercising `do/1` -> `rebar3_erli18n_host:console/2`, not a
private builder) and assert it byte-for-byte, including the `(no catalog)`
line, an explicit-`--domain` report, and a fully-translated plural counting
as `1/1`.
- **Adversarial `.po` coverage for `merge`/`check`/`report`.** Beyond the lone
truncated-`msgstr` parse error, three committed fixtures under
`providers_SUITE_data/` now drive the documented fail-soft behavior: an
**invalid-UTF-8** body (raw `0xFF 0xFE` under a `charset=UTF-8` header) makes
`merge` and `report` return a structured `{error, _}` naming the file and the
`charset_conversion` reason — and makes `check` report drift in both the
default and `--names-only` modes — never a crash; a **line-wrapped** old
msgid (`"Sign in " "to your account"`) is decoded to the same key as the
unwrapped fresh `.pot` msgid, so its translation carries over with no fuzzy
and no obsolete (pinning the wrapping-insensitive equality contract); and a
**larger** 60-entry old `.po` exercises the `read_old` parse path at scale,
carrying the one surviving key and demoting the other 59 to `#~` obsolete.
<!--
Per-package release links. The umbrella publishes each package from its own
prefixed tag (`rebar3_erli18n-vX.Y.Z`), so these point at the
`rebar3_erli18n`-scoped tags rather than a bare `vX.Y.Z` tag. See
`.github/workflows/release.yml`.
-->
[Unreleased]: https://github.com/eagle-head/erli18n/compare/rebar3_erli18n-v0.1.1...HEAD
[0.1.1]: https://github.com/eagle-head/erli18n/releases/tag/rebar3_erli18n-v0.1.1
[0.1.0]: https://github.com/eagle-head/erli18n/releases/tag/rebar3_erli18n-v0.1.0