Repository: tv-labs/lua (elixir)

Issue:
Lua.Table pretty printer
Write a pretty printer that takes Lua tables and converts them into human readable strings, for the purposes of outputting. 

For example, let's imagine the following API

```elixir
defmodule MyAPI do
  use Lua.API, scope: "api"

  deflua pretty_print(table) do
     # implement
  end
end
```

Given the following lua program using my API

```lua
local table = { a = "1", b = 2}

api.pretty_print(table)
```

Elixir will receive the table as a list of tuples

```elixir
[{"a, "1, "b", 2}]
```

It would be nice to be able to turn this table into a string from Elixir such that it creates a valid Lua table, e.g.

```elixir
assert pretty_print([{"a, "1, "b", 2}]) == ~S[{ a = "1", b = 2 }]
```

Pull request description:
Lua Table pretty printer
Adds a very basic implementation of a pretty printer that take's an Elixir representation of a table, and outputs a syntactically valid Lua table constructor (literal).

Currently doesn't nicely format large tables into multilines, and will inspect :userdata values as is.

### Example

```elixir
iex> Lua.Table.as_string([{"a", 1}, {"b", 2}])
"{a = 1, b = 2}"

iex> Lua.Table.as_string([{1, "foo"}, {2, "bar"}])
~S[{"foo", "bar"}]
```

Closes #40 

Patch:
diff --git a/lib/lua/table.ex b/lib/lua/table.ex
index 65f0485..451ca2e 100644
--- a/lib/lua/table.ex
+++ b/lib/lua/table.ex
@@ -48,6 +48,92 @@ defmodule Lua.Table do
     Map.new(values)
   end
 
+  @doc """
+  Converts a Lua table into a string representing
+  a Lua table literal
+
+      iex> Lua.Table.as_string([{"a", 1}, {"b", 2}])
+      "{a = 1, b = 2}"
+
+      iex> Lua.Table.as_string([{1, "foo"}, {2, "bar"}])
+      ~S[{"foo", "bar"}]
+
+  ### Options
+  * `:userdata` - A 1-arity function used to format userdata. Defaults to `fn _ -> "<userdata>"`
+
+  """
+  def as_string(table, opts \\ []) do
+    opts = Keyword.put_new(opts, :userdata, fn _ -> "<userdata>" end)
+
+    "{" <> print_table(table, opts[:userdata]) <> "}"
+  end
+
+  # List
+  defp print_table([{1, _} | _] = list, userdata_formatter) do
+    list
+    |> Enum.reduce([], fn {_, value}, acc ->
+      [acc, if(acc == [], do: "", else: ", "), format_value(value, userdata_formatter)]
+    end)
+    |> IO.iodata_to_binary()
+  end
+
+  # Table
+  defp print_table(table, userdata_formatter) do
+    table
+    |> Enum.reduce([], fn {key, value}, acc ->
+      key_str = format_key(key)
+      value_str = format_value(value, userdata_formatter)
+
+      entry = "#{key_str} = #{value_str}"
+
+      [acc, if(acc == [], do: "", else: ", "), entry]
+    end)
+    |> then(fn parts ->
+      IO.iodata_to_binary(parts)
+    end)
+  end
+
+  defp valid_identifier?(identifier) do
+    regex = ~r/^[[:alpha:]_][[:alnum:]_]*$/u
+    Regex.match?(regex, identifier)
+  end
+
+  defp format_key(number) when is_number(number) do
+    ["[", to_string(number), "]"]
+  end
+
+  defp format_key(key) do
+    key = to_string(key)
+
+    if valid_identifier?(key) do
+      key
+    else
+      ["[\"", key, "\"]"]
+    end
+  end
+
+  defp format_value(value, _) when is_number(value) do
+    to_string(value)
+  end
+
+  defp format_value(true, _), do: "true"
+  defp format_value(false, _), do: "false"
+  defp format_value(nil, _), do: "nil"
+
+  defp format_value(value, formatter) when is_list(value) do
+    str = print_table(value, formatter)
+
+    "{#{str}}"
+  end
+
+  defp format_value({:userdata, value}, formatter) do
+    format_value(formatter.(value), formatter)
+  end
+
+  defp format_value(value, _formatter) do
+    inspect(value)
+  end
+
   @doc """
   Converts a Lua table into more "native" feeling lists and
   maps, deeply traversing any sub-tables.


Test patch:
diff --git a/test/lua/table_test.exs b/test/lua/table_test.exs
index cc6f56d..1452ec2 100644
--- a/test/lua/table_test.exs
+++ b/test/lua/table_test.exs
@@ -1,5 +1,68 @@
 defmodule Lua.TableTest do
   use ExUnit.Case, async: true
 
+  import Lua, only: [sigil_LUA: 2]
+
   doctest Lua.Table
+
+  defmacro assert_table(table) do
+    quote bind_quoted: [table: table] do
+      assert output = Lua.Table.as_string(table)
+      assert {[ret], _lua} = Lua.eval!("return " <> output)
+      assert ret == table
+      output
+    end
+  end
+
+  describe "as_string" do
+    test "it can convert basic tables to strings" do
+      assert assert_table([]) == "{}"
+      assert assert_table([{"a", 1}, {"b", 2}]) == "{a = 1, b = 2}"
+      assert assert_table([{"a b", "value"}, {"b", 2}]) == ~S<{["a b"] = "value", b = 2}>
+    end
+
+    test "lists are converted" do
+      assert assert_table([{1, "foo"}, {2, "bar"}]) == ~S[{"foo", "bar"}]
+    end
+
+    test "it can handle tables with many keys" do
+      big_table =
+        for {letter, index} <- Enum.with_index(?a..?z) do
+          {to_string([letter]), index}
+        end
+
+      assert_table(big_table)
+    end
+
+    test "it can handle useradata" do
+      table = [{"a", 1}, {"b", {:userdata, ~D[2024-09-22]}}]
+
+      assert Lua.Table.as_string(table) == ~S[{a = 1, b = "<userdata>"}]
+
+      assert Lua.Table.as_string(table, userdata: &inspect/1) == ~S<{a = 1, b = "~D[2024-09-22]"}>
+    end
+
+    # We can't handle self-referential tables as
+    # Luerl cannot decode them
+    @tag :skip
+    test "it can handle self-referential tables" do
+      assert {[_table], _lua} =
+               Lua.eval!(~LUA"""
+               local table = { a = 1 }
+               table.nested = table
+               return table
+               """)
+    end
+
+    test "it can handle other table references" do
+      assert {[table], _lua} =
+               Lua.eval!(~LUA"""
+               local other = { c = 3 }
+               local table = { a = 1, b = other }
+               return table
+               """)
+
+      assert assert_table(table) == "{a = 1, b = {c = 3}}"
+    end
+  end
 end