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