lib/protobuf/protoc/cli.ex

defmodule Protobuf.Protoc.CLI do
  @moduledoc """
  `protoc` plugin for generating Elixir code.

  `protoc-gen-elixir` (this name is important) **must** be in `$PATH`. You are not supposed
  to call it directly, but only through `protoc`.

  ## Examples

      $ protoc --elixir_out=./lib your.proto
      $ protoc --elixir_out=plugins=grpc:./lib/ *.proto
      $ protoc -I protos --elixir_out=./lib protos/namespace/*.proto

  Options:

    * --version       Print version of protobuf-elixir
    * --help (-h)     Print this help

  """

  alias Protobuf.Protoc.Context

  # Entrypoint for the escript (protoc-gen-elixir).
  @doc false
  @spec main([String.t()]) :: :ok
  def main(args)

  def main(["--version"]) do
    {:ok, version} = :application.get_key(:protobuf, :vsn)
    IO.puts(version)
  end

  def main([opt]) when opt in ["--help", "-h"] do
    IO.puts(@moduledoc)
  end

  # When called through protoc, all input is passed through stdin.
  def main([] = _args) do
    Protobuf.load_extensions()

    # See https://groups.google.com/forum/#!topic/elixir-lang-talk/T5enez_BBTI.
    :io.setopts(:standard_io, encoding: :latin1)

    # Read the standard input that protoc feeds us.
    bin = binread_all!(:stdio)

    request = Protobuf.Decoder.decode(bin, Google.Protobuf.Compiler.CodeGeneratorRequest)

    ctx =
      %Context{}
      |> parse_params(request.parameter || "")
      |> find_types(request.proto_file, request.file_to_generate)

    {files, package_level_extensions} =
      Enum.flat_map_reduce(request.file_to_generate, %{}, fn file, acc ->
        desc = Enum.find(request.proto_file, &(&1.name == file))
        {package_level_extensions, files} = Protobuf.Protoc.Generator.generate(ctx, desc)

        acc =
          case package_level_extensions do
            {mod_name, extensions} -> Map.update(acc, mod_name, extensions, &(&1 ++ extensions))
            nil -> acc
          end

        {files, acc}
      end)

    ext_files =
      for {mod_name, extensions} <- package_level_extensions do
        {mod_name, contents} =
          Protobuf.Protoc.Generator.Extension.generate_package_level(ctx, mod_name, extensions)

        %Google.Protobuf.Compiler.CodeGeneratorResponse.File{
          name: Macro.underscore(mod_name) <> ".pb.ex",
          content: contents
        }
      end

    %Google.Protobuf.Compiler.CodeGeneratorResponse{
      file: files ++ ext_files,
      supported_features: supported_features()
    }
    |> Protobuf.encode_to_iodata()
    |> IO.binwrite()
  end

  def main(_args) do
    raise "invalid arguments. See protoc-gen-elixir --help."
  end

  def supported_features() do
    # The only available feature is proto3 with optional fields.
    # This is backwards compatible with proto2 optional fields.
    Google.Protobuf.Compiler.CodeGeneratorResponse.Feature.value(:FEATURE_PROTO3_OPTIONAL)
  end

  # Made public for testing.
  @doc false
  def parse_params(%Context{} = ctx, params_str) when is_binary(params_str) do
    params_str
    |> String.split(",")
    |> Enum.reduce(ctx, &parse_param/2)
  end

  defp parse_param("plugins=" <> plugins, ctx) do
    %Context{ctx | plugins: String.split(plugins, "+")}
  end

  defp parse_param("gen_descriptors=" <> value, ctx) do
    case value do
      "true" ->
        %Context{ctx | gen_descriptors?: true}

      other ->
        raise "invalid value for gen_descriptors option, expected \"true\", got: #{inspect(other)}"
    end
  end

  defp parse_param("package_prefix=" <> package, ctx) do
    if package == "" do
      raise "package_prefix can't be empty"
    else
      %Context{ctx | package_prefix: package}
    end
  end

  defp parse_param("transform_module=" <> module, ctx) do
    %Context{ctx | transform_module: Module.concat([module])}
  end

  defp parse_param("one_file_per_module=" <> value, ctx) do
    case value do
      "true" ->
        %Context{ctx | one_file_per_module?: true}

      other ->
        raise "invalid value for one_file_per_module option, expected \"true\", got: #{inspect(other)}"
    end
  end

  defp parse_param("include_docs=" <> value, ctx) do
    case value do
      "true" ->
        %Context{ctx | include_docs?: true}

      other ->
        raise "invalid value for include_docs option, expected \"true\", got: #{inspect(other)}"
    end
  end

  defp parse_param(_unknown, ctx) do
    ctx
  end

  # Made public for testing.
  @doc false
  @spec find_types(Context.t(), [Google.Protobuf.FileDescriptorProto.t()], [String.t()]) ::
          Context.t()
  def find_types(%Context{} = ctx, descs, files_to_generate)
      when is_list(descs) and is_list(files_to_generate) do
    global_type_mapping =
      Map.new(descs, fn %Google.Protobuf.FileDescriptorProto{name: filename} = desc ->
        {filename, find_types_in_proto(ctx, desc, files_to_generate)}
      end)

    %Context{ctx | global_type_mapping: global_type_mapping}
  end

  defp find_types_in_proto(
         %Context{} = ctx,
         %Google.Protobuf.FileDescriptorProto{} = desc,
         files_to_generate
       ) do
    # Only take package_prefix into consideration for files that we're directly generating.
    package_prefix =
      if desc.name in files_to_generate do
        ctx.package_prefix
      else
        nil
      end

    ctx =
      %Protobuf.Protoc.Context{
        namespace: [],
        package_prefix: package_prefix,
        package: desc.package
      }
      |> Protobuf.Protoc.Context.custom_file_options_from_file_desc(desc)

    find_types_in_descriptor(_types = %{}, ctx, desc.message_type ++ desc.enum_type)
  end

  defp find_types_in_descriptor(types_acc, ctx, descs) when is_list(descs) do
    Enum.reduce(descs, types_acc, &find_types_in_descriptor(_acc = &2, ctx, _desc = &1))
  end

  defp find_types_in_descriptor(
         types_acc,
         ctx,
         %Google.Protobuf.DescriptorProto{name: name} = desc
       ) do
    new_ctx = update_in(ctx.namespace, &(&1 ++ [name]))

    types_acc
    |> update_types(ctx, name)
    |> find_types_in_descriptor(new_ctx, desc.enum_type)
    |> find_types_in_descriptor(new_ctx, desc.nested_type)
  end

  defp find_types_in_descriptor(
         types_acc,
         ctx,
         %Google.Protobuf.EnumDescriptorProto{name: name}
       ) do
    update_types(types_acc, ctx, name)
  end

  defp update_types(types, %Context{namespace: ns, package: pkg} = ctx, name) do
    type_name = Protobuf.Protoc.Generator.Util.mod_name(ctx, ns ++ [name])

    mapping_name =
      ([pkg] ++ ns ++ [name])
      |> Enum.reject(&is_nil/1)
      |> Enum.join(".")

    Map.put(types, "." <> mapping_name, %{type_name: type_name})
  end

  if Version.match?(System.version(), "~> 1.13") do
    defp binread_all!(device) do
      case IO.binread(device, :eof) do
        data when is_binary(data) -> data
        :eof -> _previous_behavior = ""
        other -> raise "reading from #{inspect(device)} failed: #{inspect(other)}"
      end
    end
  else
    defp binread_all!(device) do
      case IO.binread(device, :all) do
        data when is_binary(data) -> data
        other -> raise "reading from #{inspect(device)} failed: #{inspect(other)}"
      end
    end
  end
end