1defmodule Gettext.Compiler do
2  @moduledoc false
3
4  alias Gettext.{
5    Interpolation,
6    PO,
7    PO.Translation,
8    PO.PluralTranslation
9  }
10
11  require Logger
12
13  @default_priv "priv/gettext"
14  @po_wildcard "*/LC_MESSAGES/*.po"
15
16  @doc false
17  defmacro __before_compile__(env) do
18    compile_time_opts = Module.get_attribute(env.module, :gettext_opts)
19
20    # :otp_app is only supported in "use Gettext" (because we need it to get the Mix config).
21    {otp_app, compile_time_opts} = Keyword.pop(compile_time_opts, :otp_app)
22
23    if is_nil(otp_app) do
24      # We're using Keyword.fetch!/2 to raise below.
25      Keyword.fetch!(compile_time_opts, :otp_app)
26    end
27
28    # Options given to "use Gettext" have higher precedence than options set
29    # throught Mix.Config.
30    mix_config_opts = Application.get_env(otp_app, env.module, [])
31    opts = Keyword.merge(mix_config_opts, compile_time_opts)
32
33    priv = Keyword.get(opts, :priv, @default_priv)
34    translations_dir = Application.app_dir(otp_app, priv)
35    external_file = String.replace(Path.join(".compile", priv), "/", "_")
36    known_locales = known_locales(translations_dir)
37
38    quote do
39      @behaviour Gettext.Backend
40
41      # Info about the Gettext backend.
42      @doc false
43      def __gettext__(:priv), do: unquote(priv)
44      def __gettext__(:otp_app), do: unquote(otp_app)
45      def __gettext__(:known_locales), do: unquote(known_locales)
46
47      # The manifest lives in the root of the priv
48      # directory that contains .po/.pot files.
49      @external_resource unquote(Application.app_dir(otp_app, external_file))
50
51      if Gettext.Extractor.extracting?() do
52        Gettext.ExtractorAgent.add_backend(__MODULE__)
53      end
54
55      unquote(macros())
56
57      # These are the two functions we generated inside the backend. Here we define the bodyless
58      # clauses.
59      def lgettext(locale, domain, msgid, bindings)
60      def lngettext(locale, domain, msgid, msgid_plural, n, bindings)
61
62      unquote(compile_po_files(env, translations_dir, opts))
63      unquote(catch_all_clauses())
64      unquote(catch_all_helpers())
65    end
66  end
67
68  defp macros() do
69    quote unquote: false do
70      defmacro dgettext_noop(domain, msgid) do
71        domain = Gettext.Compiler.expand_to_binary(domain, "domain", __MODULE__, __CALLER__)
72        msgid = Gettext.Compiler.expand_to_binary(msgid, "msgid", __MODULE__, __CALLER__)
73
74        if Gettext.Extractor.extracting?() do
75          Gettext.Extractor.extract(
76            __CALLER__,
77            __MODULE__,
78            domain,
79            msgid,
80            Gettext.Compiler.get_and_flush_extracted_comments()
81          )
82        end
83
84        msgid
85      end
86
87      defmacro gettext_noop(msgid) do
88        quote do
89          unquote(__MODULE__).dgettext_noop("default", unquote(msgid))
90        end
91      end
92
93      defmacro dngettext_noop(domain, msgid, msgid_plural) do
94        domain = Gettext.Compiler.expand_to_binary(domain, "domain", __MODULE__, __CALLER__)
95        msgid = Gettext.Compiler.expand_to_binary(msgid, "msgid", __MODULE__, __CALLER__)
96
97        msgid_plural =
98          Gettext.Compiler.expand_to_binary(msgid_plural, "msgid_plural", __MODULE__, __CALLER__)
99
100        if Gettext.Extractor.extracting?() do
101          Gettext.Extractor.extract(
102            __CALLER__,
103            __MODULE__,
104            domain,
105            {msgid, msgid_plural},
106            Gettext.Compiler.get_and_flush_extracted_comments()
107          )
108        end
109
110        {msgid, msgid_plural}
111      end
112
113      defmacro ngettext_noop(msgid, msgid_plural) do
114        quote do
115          unquote(__MODULE__).dngettext_noop("default", unquote(msgid), unquote(msgid_plural))
116        end
117      end
118
119      defmacro dgettext(domain, msgid, bindings \\ Macro.escape(%{})) do
120        quote do
121          msgid = unquote(__MODULE__).dgettext_noop(unquote(domain), unquote(msgid))
122          Gettext.dgettext(unquote(__MODULE__), unquote(domain), msgid, unquote(bindings))
123        end
124      end
125
126      defmacro gettext(msgid, bindings \\ Macro.escape(%{})) do
127        quote do
128          unquote(__MODULE__).dgettext("default", unquote(msgid), unquote(bindings))
129        end
130      end
131
132      defmacro dngettext(domain, msgid, msgid_plural, n, bindings \\ Macro.escape(%{})) do
133        quote do
134          {msgid, msgid_plural} =
135            unquote(__MODULE__).dngettext_noop(
136              unquote(domain),
137              unquote(msgid),
138              unquote(msgid_plural)
139            )
140
141          Gettext.dngettext(
142            unquote(__MODULE__),
143            unquote(domain),
144            msgid,
145            msgid_plural,
146            unquote(n),
147            unquote(bindings)
148          )
149        end
150      end
151
152      defmacro ngettext(msgid, msgid_plural, n, bindings \\ Macro.escape(%{})) do
153        quote do
154          unquote(__MODULE__).dngettext(
155            "default",
156            unquote(msgid),
157            unquote(msgid_plural),
158            unquote(n),
159            unquote(bindings)
160          )
161        end
162      end
163
164      defmacro gettext_comment(comment) do
165        comment = Gettext.Compiler.expand_to_binary(comment, "comment", __MODULE__, __CALLER__)
166        Gettext.Compiler.append_extracted_comment(comment)
167        :ok
168      end
169    end
170  end
171
172  @doc """
173  Returns the quoted code for the dynamic clauses of `lgettext/4` and
174  `lngettext/6`.
175  """
176  @spec catch_all_clauses() :: Macro.t()
177  def catch_all_clauses() do
178    quote do
179      def lgettext(_locale, domain, msgid, bindings) do
180        catch_all(domain, msgid, bindings)
181      end
182
183      def lngettext(_locale, domain, msgid, msgid_plural, n, bindings) do
184        catch_all_n(domain, msgid, msgid_plural, n, bindings)
185      end
186    end
187  end
188
189  @doc """
190  Defines helper functions for handling catch all throughout the backend.
191  """
192  @spec catch_all_helpers() :: Macro.t()
193  def catch_all_helpers() do
194    quote do
195      defp catch_all(domain, msgid, bindings) do
196        import Gettext.Interpolation, only: [to_interpolatable: 1, interpolate: 2]
197
198        Gettext.Compiler.warn_if_domain_contains_slashes(domain)
199
200        with {:ok, interpolated} <- interpolate(to_interpolatable(msgid), bindings) do
201          {:default, interpolated}
202        end
203      end
204
205      defp catch_all_n(domain, msgid, msgid_plural, n, bindings) do
206        import Gettext.Interpolation, only: [to_interpolatable: 1, interpolate: 2]
207
208        Gettext.Compiler.warn_if_domain_contains_slashes(domain)
209        string = if n == 1, do: msgid, else: msgid_plural
210        bindings = Map.put(bindings, :count, n)
211
212        with {:ok, interpolated} <- interpolate(to_interpolatable(string), bindings) do
213          {:default, interpolated}
214        end
215      end
216    end
217  end
218
219  @doc """
220  Expands the given `msgid` in the given `env`, raising if it doesn't expand to
221  a binary.
222  """
223  @spec expand_to_binary(binary, binary, module, Macro.Env.t()) :: binary | no_return
224  def expand_to_binary(term, what, gettext_module, env)
225      when what in ~w(domain msgid msgid_plural comment) do
226    case Macro.expand(term, env) do
227      term when is_binary(term) ->
228        term
229
230      _other ->
231        raise ArgumentError, """
232        *gettext macros expect translation keys (msgid and msgid_plural) and
233        domains to expand to strings at compile-time, but the given #{what}
234        doesn't.
235
236        Dynamic translations should be avoided as they limit gettext's
237        ability to extract translations from your source code. If you are
238        sure you need dynamic lookup, you can use the functions in the Gettext
239        module:
240
241            string = "hello world"
242            Gettext.gettext(#{inspect(gettext_module)}, string)
243        """
244    end
245  end
246
247  @doc """
248  Appends the given comment to the list of extrated comments in the process dictionary.
249  """
250  @spec append_extracted_comment(binary) :: :ok
251  def append_extracted_comment(comment) do
252    existing = Process.get(:gettext_comments, [])
253    Process.put(:gettext_comments, ["#. " <> comment | existing])
254    :ok
255  end
256
257  @doc """
258  Returns all extracted comments in the process dictionary and clears them from the process
259  dictionary.
260  """
261  @spec get_and_flush_extracted_comments() :: [binary]
262  def get_and_flush_extracted_comments() do
263    Enum.reverse(Process.delete(:gettext_comments) || [])
264  end
265
266  @doc """
267  Logs a warning via `Logger.error/1` if `domain` contains slashes.
268
269  This function is called by `lgettext` and `lngettext`. It could make sense to
270  make this function raise an error since slashes in domains are not supported,
271  but we decided not to do so and to only emit a warning since the expected
272  behaviour for Gettext functions/macros when the domain or translation is not
273  known is to return the original string (msgid) and raising here would break
274  that contract.
275  """
276  @spec warn_if_domain_contains_slashes(binary) :: :ok
277  def warn_if_domain_contains_slashes(domain) do
278    if String.contains?(domain, "/") do
279      Logger.error(["Slashes in domains are not supported: ", inspect(domain)])
280    end
281
282    :ok
283  end
284
285  @doc """
286  Compiles all the `.po` files in the given directory (`dir`) into `lgettext/4`
287  and `lngettext/6` function clauses.
288  """
289  @spec compile_po_files(Macro.Env.t(), Path.t(), Keyword.t()) :: Macro.t()
290  def compile_po_files(env, dir, opts) do
291    plural_mod = Keyword.get(opts, :plural_forms, Gettext.Plural)
292    po_files = po_files_in_dir(dir)
293
294    if Keyword.get(opts, :one_module_per_locale, false) do
295      {quoted, locales} =
296        Enum.map_reduce(po_files, %{}, &compile_parallel_po_file(env, &1, &2, plural_mod))
297
298      locales
299      |> Enum.map(&Kernel.ParallelCompiler.async(fn -> create_locale_module(env, &1) end))
300      |> Enum.each(&Task.await(&1, :infinity))
301
302      quoted
303    else
304      Enum.map(po_files, &compile_serial_po_file(&1, plural_mod))
305    end
306  end
307
308  defp create_locale_module(env, {module, translations}) do
309    exprs = [quote(do: @moduledoc(false)), catch_all_helpers() | translations]
310    Module.create(module, block(exprs), env)
311    :ok
312  end
313
314  defp compile_serial_po_file(path, plural_mod) do
315    {locale, domain, singular_fun, plural_fun, quoted} = compile_po_file(:defp, path, plural_mod)
316
317    quote do
318      unquote(quoted)
319
320      def lgettext(unquote(locale), unquote(domain), msgid, bindings) do
321        unquote(singular_fun)(msgid, bindings)
322      end
323
324      def lngettext(unquote(locale), unquote(domain), msgid, msgid_plural, n, bindings) do
325        unquote(plural_fun)(msgid, msgid_plural, n, bindings)
326      end
327    end
328  end
329
330  defp compile_parallel_po_file(env, path, locales, plural_mod) do
331    {locale, domain, singular_fun, plural_fun, locale_module_quoted} =
332      compile_po_file(:def, path, plural_mod)
333
334    module = :"#{env.module}.T_#{locale}"
335
336    current_module_quoted =
337      quote do
338        def lgettext(unquote(locale), unquote(domain), msgid, bindings) do
339          unquote(module).unquote(singular_fun)(msgid, bindings)
340        end
341
342        def lngettext(unquote(locale), unquote(domain), msgid, msgid_plural, n, bindings) do
343          unquote(module).unquote(plural_fun)(msgid, msgid_plural, n, bindings)
344        end
345      end
346
347    locales = Map.update(locales, module, [locale_module_quoted], &[locale_module_quoted | &1])
348    {current_module_quoted, locales}
349  end
350
351  # Compiles a .po file into a list of lgettext/4 (for translations) and
352  # lngettext/6 (for plural translations) clauses.
353  defp compile_po_file(kind, path, plural_mod) do
354    {locale, domain} = locale_and_domain_from_path(path)
355    %PO{translations: translations, file: file} = PO.parse_file!(path)
356
357    singular_fun = :"#{locale}_#{domain}_lgettext"
358    plural_fun = :"#{locale}_#{domain}_lngettext"
359    mapper = &compile_translation(kind, locale, &1, singular_fun, plural_fun, file, plural_mod)
360    translations = block(Enum.map(translations, mapper))
361
362    quoted =
363      quote do
364        unquote(translations)
365
366        Kernel.unquote(kind)(unquote(singular_fun)(msgid, bindings)) do
367          catch_all(unquote(domain), msgid, bindings)
368        end
369
370        Kernel.unquote(kind)(unquote(plural_fun)(msgid, msgid_plural, n, bindings)) do
371          catch_all_n(unquote(domain), msgid, msgid_plural, n, bindings)
372        end
373      end
374
375    {locale, domain, singular_fun, plural_fun, quoted}
376  end
377
378  defp locale_and_domain_from_path(path) do
379    [file, "LC_MESSAGES", locale | _rest] = path |> Path.split() |> Enum.reverse()
380    domain = Path.rootname(file, ".po")
381    {locale, domain}
382  end
383
384  defp compile_translation(
385         kind,
386         _locale,
387         %Translation{} = t,
388         singular_fun,
389         _plural_fun,
390         _file,
391         _plural_mod
392       ) do
393    msgid = IO.iodata_to_binary(t.msgid)
394    msgstr = IO.iodata_to_binary(t.msgstr)
395
396    # Only actually generate this function clause if the msgstr is not empty. If
397    # it's empty, not generating this clause (by returning `nil` from this `if`)
398    # means that the dynamic clause will be executed, returning `{:default,
399    # msgid}` (with interpolation and so on).
400    if msgstr != "" do
401      quote do
402        Kernel.unquote(kind)(unquote(singular_fun)(unquote(msgid), var!(bindings))) do
403          unquote(compile_interpolation(msgstr))
404        end
405      end
406    end
407  end
408
409  defp compile_translation(
410         kind,
411         locale,
412         %PluralTranslation{} = t,
413         _singular_fun,
414         plural_fun,
415         file,
416         plural_mod
417       ) do
418    warn_if_missing_plural_forms(locale, plural_mod, t, file)
419
420    msgid = IO.iodata_to_binary(t.msgid)
421    msgid_plural = IO.iodata_to_binary(t.msgid_plural)
422    msgstr = Enum.map(t.msgstr, fn {form, str} -> {form, IO.iodata_to_binary(str)} end)
423
424    # If any of the msgstrs is empty, then we skip the generation of this
425    # function clause. The reason we do this is the same as for the
426    # `%Translation{}` clause.
427    unless Enum.any?(msgstr, &match?({_form, ""}, &1)) do
428      # We use flat_map here because clauses can only be defined in blocks, so
429      # when quoted they are a list.
430      clauses =
431        Enum.flat_map(msgstr, fn {form, str} ->
432          quote do: (unquote(form) -> unquote(compile_interpolation(str)))
433        end)
434
435      error_clause =
436        quote do
437          form ->
438            raise Gettext.Error,
439                  "plural form #{form} is required for locale #{inspect(unquote(locale))} " <>
440                    "but is missing for translation compiled from " <>
441                    "#{unquote(file)}:#{unquote(t.po_source_line)}"
442        end
443
444      quote do
445        Kernel.unquote(kind)(
446          unquote(plural_fun)(unquote(msgid), unquote(msgid_plural), n, bindings)
447        ) do
448          plural_form = unquote(plural_mod).plural(unquote(locale), n)
449          var!(bindings) = Map.put(bindings, :count, n)
450
451          case plural_form, do: unquote(clauses ++ error_clause)
452        end
453      end
454    end
455  end
456
457  defp warn_if_missing_plural_forms(locale, plural_mod, translation, file) do
458    Enum.each(0..(plural_mod.nplurals(locale) - 1), fn form ->
459      unless Map.has_key?(translation.msgstr, form) do
460        Logger.error([
461          "#{file}:#{translation.po_source_line}: translation is missing plural form ",
462          Integer.to_string(form),
463          " which is required by the locale ",
464          inspect(locale)
465        ])
466      end
467    end)
468  end
469
470  defp block(contents) when is_list(contents) do
471    {:__block__, [], contents}
472  end
473
474  # Compiles a string into a full-blown `case` statement which interpolates the
475  # string based on some bindings or returns an error in case those bindings are
476  # missing. Note that the `bindings` variable is assumed to be in the scope by
477  # the quoted code that is returned.
478  defp compile_interpolation(str) do
479    compile_interpolation(str, Interpolation.keys(str))
480  end
481
482  defp compile_interpolation(str, [] = _keys) do
483    quote do
484      _ = var!(bindings)
485      {:ok, unquote(str)}
486    end
487  end
488
489  defp compile_interpolation(str, keys) do
490    match = compile_interpolation_match(keys)
491    interpolation = compile_interpolatable_string(str)
492    interpolatable = Interpolation.to_interpolatable(str)
493
494    quote do
495      case var!(bindings) do
496        unquote(match) ->
497          {:ok, unquote(interpolation)}
498
499        %{} ->
500          Gettext.Interpolation.interpolate(unquote(interpolatable), var!(bindings))
501      end
502    end
503  end
504
505  # Compiles a list of atoms into a "match" map. For example `[:foo, :bar]` gets
506  # compiled to `%{foo: foo, bar: bar}`. All generated variables are under the
507  # current `__MODULE__`.
508  defp compile_interpolation_match(keys) do
509    {:%{}, [], Enum.map(keys, &{&1, Macro.var(&1, __MODULE__)})}
510  end
511
512  # Compiles a string into a sequence of applications of the `<>` operator.
513  # `%{var}` patterns are turned into `var` variables, namespaced inside the
514  # current `__MODULE__`. Heavily inspired by Chris McCord's "linguist", see
515  # https://github.com/chrismccord/linguist/blob/master/lib/linguist/compiler.ex#L70
516  defp compile_interpolatable_string(str) do
517    Enum.reduce(Interpolation.to_interpolatable(str), "", fn
518      key, acc when is_atom(key) ->
519        quote do: unquote(acc) <> to_string(unquote(Macro.var(key, __MODULE__)))
520
521      str, acc ->
522        quote do: unquote(acc) <> unquote(str)
523    end)
524  end
525
526  # Returns all the PO files in `translations_dir` (under "canonical" paths,
527  # that is, `locale/LC_MESSAGES/domain.po`).
528  defp po_files_in_dir(dir) do
529    dir
530    |> Path.join(@po_wildcard)
531    |> Path.wildcard()
532  end
533
534  # Returns all the locales in `translations_dir` (which are the locales known
535  # by the compiled backend).
536  defp known_locales(translations_dir) do
537    case File.ls(translations_dir) do
538      {:ok, files} ->
539        Enum.filter(files, &File.dir?(Path.join(translations_dir, &1)))
540
541      {:error, :enoent} ->
542        []
543
544      {:error, reason} ->
545        raise File.Error, reason: reason, action: "list directory", path: translations_dir
546    end
547  end
548end
549