lib/mix/protobuf.generate.ex

defmodule Mix.Tasks.Protobuf.Generate do
  @moduledoc """
  Generate Elixir code from `.proto` files.

  ## Arguments

  * `file` - One or more `.proto` files to compile

  ## Required options

  * `--output-path` - Path to output directory

  ## Optional options

  * `--include-path` - Specify the directory in which to search for imports. Eqvivalent to `protoc` `-I` flag.
  * `--tranform-module` - Module to do custom encoding/decoding for messages. See `Protobuf.TransformModule` for details.
  * `--package-prefix` - Prefix generated Elixir modules. For example prefix modules with: `MyApp.Protos` use `--package-prefix=my_app.protos`.
  * `--generate-descriptors` - Includes raw descriptors in the generated modules
  * `--one-file-per-module` - Changes the way files are generated into directories. This option creates a file for each generated Elixir module.
  * `--include-documentation` - Controls visibility of documentation of the generated modules. Setting `true` will not  have `@moduleoc false`
  * `--plugin` - If you write services in protobuf, you can generate gRPC code by passing `--plugin=grpc`.

  ## Examples

      $ mix protobuf.generate --output-path=./lib --include-path=./priv/protos helloworld.proto

      $ mix protobuf.generate \
        --include-path=priv/proto \
        --include-path=deps/googleapis \
        --generate-descriptors=true \
        --output-path=./lib \
        --plugin=ProtobufGenerate.Plugins.GRPC
        google/api/annotations.proto google/api/http.proto helloworld.proto

  """
  @shortdoc "Generate Elixir code from Protobuf definitions"
  @requirements ["app.config"]

  use Mix.Task

  alias ProtobufGenerate.{Protoc, CodeGen, Plugin}
  alias Protobuf.Protoc.Context

  @switches [
    output_path: :string,
    include_path: :keep,
    generate_descriptors: :boolean,
    package_prefix: :string,
    transform_module: :string,
    include_docs: :boolean,
    one_file_per_module: :boolean,
    plugin: :keep
  ]

  @impl Mix.Task
  @spec run(any) :: any
  def run(args) do
    {opts, files} = OptionParser.parse!(args, strict: @switches)
    {plugins, opts} = pop_values(opts, :plugin)
    {imports, opts} = pop_values(opts, :include_path)

    transform_module =
      case Keyword.fetch(opts, :transform_module) do
        {:ok, t} -> Module.concat([t])
        :error -> nil
      end

    output_path =
      opts
      |> Keyword.fetch!(:output_path)
      |> Path.expand()

    init(args)

    case Protoc.run(files, imports) do
      {:ok, bin} ->
        ctx = %Context{
          gen_descriptors?: Keyword.get(opts, :generate_descriptors, false),
          plugins: plugins,
          transform_module: transform_module,
          package_prefix: Keyword.get(opts, :package_prefix),
          include_docs?: Keyword.get(opts, :include_docs, false)
        }

        request = decode(files, imports, bin)
        response = generate(ctx, request)

        Enum.each(response.file, &generate_file(output_path, &1))

      {:error, reason} ->
        IO.puts(:stderr, "Failed to generate code: #{inspect(reason)}")
        exit({:shutdown, 1})
    end
  end

  defp init(_args) do
    Application.ensure_all_started(:protobuf)
    Protobuf.load_extensions()
  end

  defp decode(files, imports, bin) do
    %Google.Protobuf.FileDescriptorSet{file: file_descriptors} =
      Protobuf.Decoder.decode(bin, Google.Protobuf.FileDescriptorSet)

    files = normalize_import_paths(files, imports, [])

    %Google.Protobuf.Compiler.CodeGeneratorRequest{
      file_to_generate: files,
      proto_file: file_descriptors
    }
  end

  defp generate(ctx, request) do
    ctx = Protobuf.Protoc.CLI.find_types(ctx, request.proto_file, request.file_to_generate)

    plugins =
      [
        ProtobufGenerate.Plugins.Enum,
        ProtobufGenerate.Plugins.Extension,
        ProtobufGenerate.Plugins.Message
      ] ++ Plugin.load(ctx.plugins)

    files =
      Enum.flat_map(request.file_to_generate, fn file ->
        desc = Enum.find(request.proto_file, &(&1.name == file))
        CodeGen.generate(ctx, desc, plugins)
      end)

    response = %Google.Protobuf.Compiler.CodeGeneratorResponse{
      file: files,
      supported_features: Protobuf.Protoc.CLI.supported_features()
    }

    response
  end

  defp generate_file(output_path, %{name: file_name, content: content}) do
    path = Path.join([output_path, file_name])
    dir = Path.dirname(path)

    File.mkdir_p!(dir)
    File.write!(path, content)
  end

  defp pop_values(opts, key) do
    {values, new_opts} =
      Enum.reduce(opts, {[], []}, fn
        {^key, value}, {values, new_opts} -> {[value | values], new_opts}
        {key, value}, {values, new_opts} -> {values, [{key, value} | new_opts]}
      end)

    {Enum.reverse(values), Enum.reverse(new_opts)}
  end

  defp normalize_import_paths(files, [], _), do: files
  defp normalize_import_paths([], _, acc), do: Enum.reverse(acc)

  defp normalize_import_paths([file | rest], imports, acc) do
    file_path =
      Enum.reduce_while(imports, file, fn i, file ->
        relative_path = Path.relative_to(file, i)

        if relative_path == file do
          {:cont, file}
        else
          {:halt, relative_path}
        end
      end)

    normalize_import_paths(rest, imports, [file_path | acc])
  end
end