From 966a006296f429cc498cd48444ce59dbeb2c4068 Mon Sep 17 00:00:00 2001 From: Oliver Kriska Date: Tue, 16 Jun 2026 10:24:57 +0200 Subject: [PATCH 1/3] Avoid reading each existing POT file twice during extraction `read_contents_and_parse/1` read the file with `File.read!/1` and then called `PO.parse_file!/2`, which reads the very same file again internally. Pass the contents we already have to `PO.parse_string!/2` instead. `PO.parse_file!(path, file: path)` is defined as `File.read` followed by `parse_string(contents, file: path)`, so the parsed result is identical; the existing `File.read!/1` still raises on a missing file. This removes one disk read per existing POT file on every `mix gettext.extract`. --- lib/gettext/extractor.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gettext/extractor.ex b/lib/gettext/extractor.ex index 0def681..f2b5f52 100644 --- a/lib/gettext/extractor.ex +++ b/lib/gettext/extractor.ex @@ -462,7 +462,7 @@ defmodule Gettext.Extractor do defp read_contents_and_parse(path) do contents = File.read!(path) - {contents, PO.parse_file!(path, file: path)} + {contents, PO.parse_string!(contents, file: path)} end # This function "tags" a {path, _} tuple in order to distinguish POT files From a1dc958bb50c14faf687af995430327b700d74e7 Mon Sep 17 00:00:00 2001 From: Oliver Kriska Date: Tue, 16 Jun 2026 10:25:41 +0200 Subject: [PATCH 2/3] Compute plural info once per PO file at compile time `compile_po_file/5` derived the plural data twice: once in `nplurals/3` and once in `compile_plural_forms/4`, each calling `Plural.plural_info/3`. That function runs `Code.ensure_compiled!/1` and parses the `Plural-Forms` header, so it is needless work repeated for every PO file across all locales and domains. Compute `plural_info` once in `compile_po_file/5` and pass it to both helpers. The generated code is identical (the same value is escaped into the plural dispatcher and fed to `nplurals/1`); only the duplicate computation is removed. --- CHANGELOG.md | 1 + lib/gettext/compiler.ex | 16 +++++++--------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be97a39..a23c244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased + * Call `c:Gettext.Plural.init/1` once per PO file at compile time instead of twice. * Add experimental `mix gettext.generate` task: messages are persisted as module attributes during normal compilation (for backends with `automatic_extraction: true` in the application environment, for example `config :gettext, MyApp.Gettext, automatic_extraction: true` in `config/dev.exs`) and read back from the compiled BEAM files, so extraction no longer needs to force-recompile the project. ## v1.1.0 diff --git a/lib/gettext/compiler.ex b/lib/gettext/compiler.ex index 712f632..c6270a9 100644 --- a/lib/gettext/compiler.ex +++ b/lib/gettext/compiler.ex @@ -498,8 +498,9 @@ defmodule Gettext.Compiler do plural_forms_fun = :"#{locale}_#{domain}_plural" - plural_forms = compile_plural_forms(locale, messages_struct, plural_mod, plural_forms_fun) - nplurals = nplurals(locale, messages_struct, plural_mod) + plural_info = Plural.plural_info(locale, messages_struct, plural_mod) + plural_forms = compile_plural_forms(plural_info, plural_mod, plural_forms_fun) + nplurals = nplurals(plural_info, plural_mod) singular_fun = :"#{locale}_#{domain}_lgettext" plural_fun = :"#{locale}_#{domain}_lngettext" @@ -552,17 +553,14 @@ defmodule Gettext.Compiler do {locale, domain, singular_fun, plural_fun, quoted} end - defp nplurals(locale, messages_struct, plural_mod) do - plural_mod.nplurals(Plural.plural_info(locale, messages_struct, plural_mod)) + defp nplurals(plural_info, plural_mod) do + plural_mod.nplurals(plural_info) end - defp compile_plural_forms(locale, messages_struct, plural_mod, plural_fun) do + defp compile_plural_forms(plural_info, plural_mod, plural_fun) do quote do defp unquote(plural_fun)(n) do - unquote(plural_mod).plural( - unquote(Macro.escape(Plural.plural_info(locale, messages_struct, plural_mod))), - n - ) + unquote(plural_mod).plural(unquote(Macro.escape(plural_info)), n) end end end From 7ff83017425c961f6231366d44573b89dca3da60 Mon Sep 17 00:00:00 2001 From: Oliver Kriska Date: Thu, 15 Oct 2026 14:28:50 +0200 Subject: [PATCH 3/3] Compile the interpolation patterns once per VM to_interpolatable/1 compiled the "%{" and "}" patterns with :binary.compile_pattern/1 on every call. Each compilation costs about as much as splitting a short string, and this function runs on every runtime interpolation: whenever the current locale has no translation for a message, including every render in the default locale of an application without a PO file for it, and once per message at compile time. A compiled pattern is a reference, so it cannot live in a module attribute; the two patterns are now compiled on first use and kept in persistent_term. Passing the literal strings to :binary.split/2 instead would compile them again on every split and gets slower as the number of bindings grows. Microbenchmark of runtime_interpolate/2 (compiled loop, median of 7 alternating samples, Elixir 1.20.4 / OTP 29, one scheduler; OTP 27 within 5% of these): input before after no bindings 0.44 us 0.15 us 1 binary binding 0.72 us 0.39 us 3 binary bindings 1.06 us 0.78 us 32 bindings 7.76 us 7.27 us long text, 1 binding 0.75 us 0.45 us --- lib/gettext/interpolation/default.ex | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/gettext/interpolation/default.ex b/lib/gettext/interpolation/default.ex index 4d2c1a1..dbac30f 100644 --- a/lib/gettext/interpolation/default.ex +++ b/lib/gettext/interpolation/default.ex @@ -22,14 +22,30 @@ defmodule Gettext.Interpolation.Default do @doc false @spec to_interpolatable(String.t()) :: interpolatable() def to_interpolatable(string) when is_binary(string) do - start_pattern = :binary.compile_pattern("%{") - end_pattern = :binary.compile_pattern("}") + {start_pattern, end_pattern} = patterns() string |> to_interpolatable(_current = "", _acc = [], start_pattern, end_pattern) |> Enum.reverse() end + # The patterns are constant, but a compiled pattern is a reference, so it + # cannot be a module attribute. Compiling them on every call costs more than + # the split itself for short strings, so they are compiled once per VM. + @patterns_key {__MODULE__, :patterns} + + defp patterns do + case :persistent_term.get(@patterns_key, nil) do + nil -> + patterns = {:binary.compile_pattern("%{"), :binary.compile_pattern("}")} + :persistent_term.put(@patterns_key, patterns) + patterns + + patterns -> + patterns + end + end + defp to_interpolatable(string, current, acc, start_pattern, end_pattern) do case :binary.split(string, start_pattern) do # If we have one element, no %{ was found so this is the final part of the