lib/mix/tasks/gettext.extract.ex

defmodule Mix.Tasks.Gettext.Extract do
  use Mix.Task
  @recursive true

  @shortdoc "Extracts translations from source code"

  @moduledoc """
  Extracts translations by recompiling the Elixir source code.

      mix gettext.extract [OPTIONS]

  Translations are extracted into POT (Portable Object Template) files (with a
  `.pot` extension). The location of these files is determined by the `:otp_app`
  and `:priv` options given by Gettext modules when they call `use Gettext`. One
  POT file is generated for each translation domain.

  All automatically extracted translations are assigned the `elixir-autogen` flag.
  If a translation from the POT is no longer present and has the `elixir-autogen`
  flag, the translation will be removed.

  Before `v0.19.0`, the `elixir-format` flag was used to detect automatically
  extracted translations. This has been deprecated in `v0.19.0`. When extracting
  with the newest version, the new `elixir-autogen` flag will be added to all
  automatically extracted translations.

  All translations will be assigned a format flag. When using the default
  interpolation module, that flag is `elixir-format`. With other interpolation
  modules, the flag name is defined by that implementation.

  If you would like to verify that your POT files are up to date with the
  current state of the codebase, you can provide the `--check-up-to-date`
  flag. This is particularly useful for automated checks and in CI systems.
  This validation will fail even when the same calls to gettext
  only change location in the codebase:

      mix gettext.extract --check-up-to-date

  It is possible to give the `--merge` option to perform merging
  for every Gettext backend updated during merge:

      mix gettext.extract --merge

  All other options passed to `gettext.extract` are forwarded to the
  `gettext.merge` task (`Mix.Tasks.Gettext.Merge`), which is called internally
  by this task. For example:

      mix gettext.extract --merge --no-fuzzy

  """

  @switches [merge: :boolean, check_up_to_date: :boolean]

  def run(args) do
    Application.ensure_all_started(:gettext)
    _ = Mix.Project.get!()
    mix_config = Mix.Project.config()
    {opts, _} = OptionParser.parse!(args, switches: @switches)
    pot_files = extract(mix_config[:app], mix_config[:gettext] || [])

    if opts[:check_up_to_date] do
      run_up_to_date_check(pot_files)
    else
      run_translation_extraction(pot_files, opts, args)
    end
  end

  defp run_translation_extraction(pot_files, opts, args) do
    for {path, contents} <- pot_files do
      File.mkdir_p!(Path.dirname(path))
      File.write!(path, contents)
      Mix.shell().info("Extracted #{Path.relative_to_cwd(path)}")
    end

    if opts[:merge] do
      run_merge(pot_files, args)
    end

    :ok
  end

  defp run_up_to_date_check(pot_files) do
    not_extracted_paths =
      for {path, contents} <- pot_files,
          not File.exists?(path) or File.read!(path) != contents,
          do: path

    if not_extracted_paths == [] do
      :ok
    else
      Mix.raise("""
      mix gettext.extract failed due to --check-up-to-date.
      The following POT files were not extracted or are out of date:

      #{Enum.map_join(not_extracted_paths, "\n", &"  * #{&1 |> Path.relative_to_cwd()}")}
      """)
    end
  end

  defp extract(app, gettext_config) do
    Gettext.Extractor.enable()
    force_compile()
    Gettext.Extractor.pot_files(app, gettext_config)
  after
    Gettext.Extractor.disable()
  end

  defp force_compile do
    Enum.map(Mix.Tasks.Compile.Elixir.manifests(), &make_old_if_exists/1)

    # If "compile" was never called, the reenabling is a no-op and
    # "compile.elixir" is a no-op as well (because it wasn't reenabled after
    # running "compile"). If "compile" was already called, then running
    # "compile" is a no-op and running "compile.elixir" will work because we
    # manually reenabled it.
    Mix.Task.reenable("compile.elixir")
    Mix.Task.run("compile")
    Mix.Task.run("compile.elixir", ["--force"])
  end

  defp make_old_if_exists(path) do
    :file.change_time(path, {{2000, 1, 1}, {0, 0, 0}})
  end

  defp run_merge(pot_files, argv) do
    pot_files
    |> Enum.map(fn {path, _} -> Path.dirname(path) end)
    |> Enum.uniq()
    |> Task.async_stream(&Mix.Tasks.Gettext.Merge.run([&1 | argv]), ordered: false)
    |> Stream.run()
  end
end