Skip to content

Commit

Permalink
Use Inspect.Algebra in Protobuf.Text
Browse files Browse the repository at this point in the history
Makes the code a lot simpler.
  • Loading branch information
v0idpwn committed Jan 23, 2025
1 parent ea071b2 commit 6c73550
Show file tree
Hide file tree
Showing 2 changed files with 64 additions and 197 deletions.
218 changes: 53 additions & 165 deletions lib/protobuf/text.ex
Original file line number Diff line number Diff line change
@@ -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)}"
Expand All @@ -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)
Expand All @@ -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?
Expand All @@ -252,4 +138,6 @@ defmodule Protobuf.Text do
end

defp skip_field?(_, _, _), do: false

defp inspect_opts(), do: %Inspect.Opts{limit: :infinity}
end
43 changes: 11 additions & 32 deletions test/protobuf/text_test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ defmodule Protobuf.TextTest do

assert result == """
{
a: 1
a: 1,
g: [
1111111111,
1111111111,
Expand All @@ -37,7 +37,7 @@ defmodule Protobuf.TextTest do
1111111111,
1111111111,
1111111111
]
],
j: D
}\
"""
Expand All @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -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
Expand Down

0 comments on commit 6c73550

Please sign in to comment.