From 6c73550f9ba74b6c9716d25010528f31ae1610c6 Mon Sep 17 00:00:00 2001 From: v0idpwn Date: Thu, 23 Jan 2025 00:20:00 -0300 Subject: [PATCH] Use `Inspect.Algebra` in Protobuf.Text Makes the code a lot simpler. --- lib/protobuf/text.ex | 218 +++++++++---------------------------- test/protobuf/text_test.ex | 43 ++------ 2 files changed, 64 insertions(+), 197 deletions(-) diff --git a/lib/protobuf/text.ex b/lib/protobuf/text.ex index 32310be7..b8810c48 100644 --- a/lib/protobuf/text.ex +++ b/lib/protobuf/text.ex @@ -1,98 +1,73 @@ defmodule Protobuf.Text do - @moduledoc """ - Text encoding of protobufs - - According to https://protobuf.dev/reference/protobuf/textformat-spec/, without - extensions or `Google.Protobuf.Any` support. - - Useful for inspecting/debugging protobuf-encoded data. - - > #### Warning {: .warning} - > - > This module doesn't perform any validation in the inputs. If the input data - > is invalid, it produces undecodable output. - - """ - alias Protobuf.FieldProps alias Protobuf.MessageProps - - @typep acc :: %{ - pad: pos_integer(), - pad_size: pos_integer(), - current_line_width: pos_integer(), - max_line_width: pos_integer(), - newline?: boolean(), - should_pad?: boolean() - } - - @default_acc %{ - pad: 0, - pad_size: 2, - current_line_width: 0, - max_line_width: 80, - newline?: false, - should_pad?: true - } + alias Inspect.Algebra @doc """ Encodes a protobuf struct to text. Accepts the following options: - - `:pad_size` - indentation size, in number of spaces - - `:max_line_width` - maximum line width, in columns + - `:max_line_width` - maximum line width, in columns. Defaults to 80. - Doesn't perform any validations. If input data is invalid, it produces + Doesn't perform type validations. If input data is invalid, it produces undecodable output. """ + @spec encode(struct(), Keyword.t()) :: binary() def encode(struct, opts \\ []) do - %{syntax: syntax} = message_props = struct.__struct__.__message_props__() - - opts_map = - opts - |> Keyword.take([:pad_size, :max_line_width]) - |> Map.new() + max_line_width = Keyword.get(opts, :max_line_width, 80) + message_props = struct.__struct__.__message_props__() struct |> transform_module(struct.__struct__) - |> encode_struct(syntax, message_props, Map.merge(@default_acc, opts_map)) + |> encode_struct(message_props) + |> Algebra.format(max_line_width) |> IO.iodata_to_binary() end - @spec encode_field( - {atom(), term()}, - :proto2 | :proto3, - MessageProps.t(), - acc() - ) :: iodata() - defp encode_field({name, value}, syntax, message_props, acc) do + @spec encode_struct(struct() | nil, MessageProps.t()) :: Algebra.t() + defp encode_struct(%_{} = struct, message_props) do + %{syntax: syntax} = message_props + + fields = + struct + |> Map.from_struct() + |> Map.drop([:__unknown_fields__, :__struct__, :__pb_extensions__]) + |> Enum.sort() + + fun = fn value, _opts -> + encode_struct_field(value, syntax, message_props) + end + + Algebra.container_doc("{", fields, "}", inspect_opts(), fun, break: :strict) + end + + defp encode_struct(nil, _) do + "{}" + end + + @spec encode_struct_field({atom(), term()}, :proto2 | :proto3, MessageProps.t()) :: Algebra.t() + defp encode_struct_field({name, value}, syntax, message_props) do case Enum.find(message_props.field_props, fn {_, prop} -> prop.name_atom == name end) do {_fnum, field_prop} -> if skip_field?(syntax, value, field_prop) do - [] + Algebra.empty() else - enc_name = to_string(name) - acc = incr_width(acc, String.length(enc_name) + 3) - - [ - pad(acc), + Algebra.concat([ to_string(name), - ?:, - ?\s, - encode_value(value, syntax, field_prop, %{acc | should_pad?: false}), - newline(acc) - ] + ": ", + encode_value(value, syntax, field_prop) + ]) end nil -> if Enum.any?(message_props.oneof, fn {oneof_name, _} -> name == oneof_name end) do case value do {field_name, field_value} -> - encode_field({field_name, field_value}, syntax, message_props, acc) + encode_struct_field({field_name, field_value}, syntax, message_props) nil -> - [] + Algebra.empty() _ -> raise "Invalid value for oneof `#{inspect(name)}`: #{inspect(value)}" @@ -103,125 +78,37 @@ defmodule Protobuf.Text do end end - @spec encode_value(term(), :proto2 | :proto3, FieldProps.t(), acc()) :: iodata() - defp encode_value(value, syntax, %{repeated?: true} = field_prop, acc) when is_list(value) do - preencoded = - value - |> Enum.map(&encode_value(&1, syntax, field_prop, %{acc | should_pad?: false})) - |> Enum.intersperse([?,, ?\s]) - - # For [] - acc = incr_width(acc, 2) - - # If the first attempt at encoding would break maximum line size, we re-encode - # breaking into multiple lines - if exceeding_max_width?(preencoded, acc) do - nacc = set_newline(acc) - - encoded = - value - |> Enum.map(&encode_value(&1, syntax, field_prop, nacc)) - |> Enum.intersperse([?,, ?\n]) - - [?[, ?\n, encoded, ?\n, pad(%{acc | should_pad?: true}), ?]] - else - [?[, preencoded, ?]] - end + @spec encode_value(term(), :proto2 | :proto3, FieldProps.t()) :: Algebra.t() + defp encode_value(value, syntax, %{repeated?: true} = field_prop) when is_list(value) do + fun = fn val, _opts -> encode_value(val, syntax, field_prop) end + Algebra.container_doc("[", value, "]", inspect_opts(), fun, break: :strict) end - defp encode_value(value, syntax, %{map?: true, repeated?: false} = field_prop, acc) do + defp encode_value(value, syntax, %{map?: true, repeated?: false} = field_prop) do as_list = Enum.map(value, fn {k, v} -> struct(field_prop.type, key: k, value: v) end) - encode_value(as_list, syntax, %{field_prop | repeated?: true}, acc) + encode_value(as_list, syntax, %{field_prop | repeated?: true}) end - defp encode_value(value, syntax, %{embedded?: true, type: mod}, acc) do + defp encode_value(value, _syntax, %{embedded?: true, type: mod}) do value |> transform_module(mod) - |> encode_struct(syntax, mod.__message_props__(), acc) + |> encode_struct(mod.__message_props__()) end - defp encode_value(nil, :proto2, %FieldProps{required?: true, name_atom: name}, _) do + defp encode_value(nil, :proto2, %FieldProps{required?: true, name_atom: name}) do raise "field #{inspect(name)} is required" end - defp encode_value(value, _, _, acc) when is_atom(value) do - [pad(acc), to_string(value)] - end - - defp encode_value(value, _, _, acc) do - [pad(acc), inspect(value)] - end - - @spec encode_struct(term(), :proto2 | :proto3, MessageProps.t(), acc()) :: iodata() - defp encode_struct(nil, _, _, _) do - [?{, ?}] - end - - defp encode_struct(%_{} = struct, syntax, message_props, acc) do - fields = - struct - |> Map.from_struct() - |> Map.drop([:__unknown_fields__, :__struct__, :__pb_extensions__]) - |> Enum.sort() - - preencoded_fields = - fields - |> Enum.map( - &encode_field(&1, syntax, message_props, %{acc | should_pad?: false, newline?: false}) - ) - |> Enum.reject(&Enum.empty?/1) - |> Enum.intersperse([?,, ?\s]) - - # For {} - acc = incr_width(acc, 2) - - # If the first attempt at encoding would break maximum line size, we re-encode - # breaking into multiple lines - if exceeding_max_width?(preencoded_fields, acc) do - nacc = set_newline(acc) - - encoded_fields = - fields - |> Enum.map(&encode_field(&1, syntax, message_props, nacc)) - |> Enum.reject(&Enum.empty?/1) - - [pad(acc), ?{, ?\n, encoded_fields, pad(%{acc | should_pad?: true}), ?}] - else - [pad(acc), ?{, preencoded_fields, ?}] - end - end - - @spec exceeding_max_width?(iodata(), acc()) :: boolean() - defp exceeding_max_width?(preencoded, acc) do - IO.iodata_length(preencoded) + acc.current_line_width > acc.max_line_width - end - - @spec set_newline(acc()) :: acc() - defp set_newline(acc) do - %{ - acc - | newline?: true, - pad: acc.pad + acc.pad_size, - should_pad?: true, - current_line_width: acc.pad + acc.pad_size - } + defp encode_value(value, _, _) when is_atom(value) do + to_string(value) end - @spec incr_width(acc(), integer()) :: acc() - defp incr_width(acc, val) do - %{acc | current_line_width: acc.current_line_width + val} + defp encode_value(value, _, _) do + inspect(value) end - @spec pad(acc()) :: iodata() - defp pad(%{should_pad?: false}), do: [] - defp pad(%{pad: pad}), do: List.duplicate(?\s, pad) - - @spec newline(acc()) :: iodata() - defp newline(%{newline?: true}), do: ?\n - defp newline(%{newline?: false}), do: [] - defp transform_module(message, module) do if transform_module = module.transform_module() do transform_module.encode(message, module) @@ -230,7 +117,6 @@ defmodule Protobuf.Text do end end - # Copied from Protobuf.Encoder. Should this be somewhere else? defp skip_field?(_syntax, [], _prop), do: true defp skip_field?(_syntax, val, _prop) when is_map(val), do: map_size(val) == 0 defp skip_field?(:proto2, nil, %FieldProps{optional?: optional?}), do: optional? @@ -252,4 +138,6 @@ defmodule Protobuf.Text do end defp skip_field?(_, _, _), do: false + + defp inspect_opts(), do: %Inspect.Opts{limit: :infinity} end diff --git a/test/protobuf/text_test.ex b/test/protobuf/text_test.ex index cc8922ba..90d71cf8 100644 --- a/test/protobuf/text_test.ex +++ b/test/protobuf/text_test.ex @@ -28,7 +28,7 @@ defmodule Protobuf.TextTest do assert result == """ { - a: 1 + a: 1, g: [ 1111111111, 1111111111, @@ -37,7 +37,7 @@ defmodule Protobuf.TextTest do 1111111111, 1111111111, 1111111111 - ] + ], j: D }\ """ @@ -63,15 +63,15 @@ defmodule Protobuf.TextTest do assert result == """ { - a: 1 + a: 1, e: { - a: 1 + a: 1, b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello" - } + }, h: [ {a: 5}, { - a: 1 + a: 1, b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello" }, {a: 7} @@ -115,18 +115,18 @@ defmodule Protobuf.TextTest do assert result_with_small_limit == """ { - a: 1 + a: 1, e: { - a: 1 + a: 1, b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello" - } + }, h: [ { - a: 5 + a: 5, b: "hi" }, { - a: 1 + a: 1, b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello" }, {a: 7} @@ -135,27 +135,6 @@ defmodule Protobuf.TextTest do """ end - test "respecs pad_size option" do - result = - Text.encode(%TestMsg.Foo{a: 1, g: List.duplicate(1_111_111_111, 7), j: :D}, pad_size: 4) - - assert result == """ - { - a: 1 - g: [ - 1111111111, - 1111111111, - 1111111111, - 1111111111, - 1111111111, - 1111111111, - 1111111111 - ] - j: D - }\ - """ - end - test "encoding oneofs" do assert "{a: 50}" == Text.encode(%TestMsg.Oneof{first: {:a, 50}}) end