Current section

Files

Jump to
toml lib builder.ex
Raw

lib/builder.ex

defmodule Toml.Builder do
@moduledoc false
@compile inline: [key: 3, keys: 2, comment: 2, open: 2, close: 1, to_result: 1]
# Builds a Document, performing validation along the way
alias Toml.Document
@doc """
Creates a new empty TOML document to build with.
"""
defdelegate new(opts), to: Document
@doc """
Starts a new table and sets the context for subsequent key/values
"""
def push_table(%Document{} = doc, keypath) do
with {:ok, doc} = push_key(%Document{doc | open_table: nil}, keypath, %{}) do
# We're explicitly opening a new table
doc |> open(keypath) |> to_result()
end
end
@doc """
Starts a new array of tables and sets the context for subsequent key/values
"""
def push_table_array(%Document{} = doc, keypath) do
with {:ok, doc} = push_key(%Document{doc | open_table: nil}, keypath, {:table_array, []}) do
# We're explicitly opening a new table
doc |> open(keypath) |> to_result()
end
end
@doc """
Push a comment on a stack containing lines of comments applying to some element.
Comments are collected and assigned to key paths when a key is set, table created, etc.
"""
def push_comment(%Document{comment_stack: cs} = doc, comment) do
%Document{doc | comment_stack: [comment | cs]}
end
@doc """
Push a value for a key into the TOML document.
This operation is used when any key/value pair is set, and table or table array is defined.
Based on the key the type of the value provided, the behavior of this function varies, as validation
as performed as part of setting the key, to ensure that redefining keys is prohibited, but that setting
child keys of existing tables is allowed. Table arrays considerably complicate this unfortunately.
"""
def push_key(%Document{keys: ks} = doc, [key], value) when is_map(value) and map_size(value) == 0 do
# New table
keypath = [key]
case Map.get(ks, key) do
nil ->
doc
|> key(key, %{})
|> comment(keypath)
|> open(keypath)
|> to_result()
exists when is_map(exists) ->
cond do
map_size(exists) == 0 ->
# Redefinition
key_exists!(keypath)
Enum.all?(exists, fn {_, v} when is_map(v) -> true; _ -> false end) ->
# All keys are sub-tables, we'll allow this
doc
|> comment(keypath)
|> open(keypath)
|> to_result()
end
_exists ->
key_exists!(keypath)
end
end
def push_key(%Document{keys: ks} = doc, [key], value) when is_map(value) do
# Pushing an inline table
keypath = [key]
case push_key_into_table(ks, [key], value) do
{:ok, ks} ->
doc
|> keys(ks)
|> comment(keypath)
|> close()
|> to_result()
{:error, :key_exists} ->
key_exists!(keypath)
end
end
def push_key(%Document{keys: ks} = doc, [key], {:table_array, _} = value) do
keypath = [key]
case push_key_into_table(ks, [key], value) do
{:ok, ks} ->
doc
|> keys(ks)
|> comment(keypath)
|> close()
|> to_result()
{:error, :key_exists} ->
key_exists!(keypath)
end
end
def push_key(%Document{keys: ks, open_table: nil} = doc, [key], value) do
# Pushing a key/value pair at the root level of the document
keypath = [key]
if Map.has_key?(ks, key) do
key_exists!(keypath)
end
doc
|> key(key, value)
|> comment(keypath)
|> close()
|> to_result()
end
def push_key(%Document{keys: ks, open_table: opened} = doc, [key], value) do
# Pushing a key/value pair when a table is open
keypath = opened ++ [key]
case push_key_into_table(ks, keypath, value) do
{:ok, ks} ->
doc
|> keys(ks)
|> comment(keypath)
|> to_result()
{:error, :key_exists} ->
key_exists!(keypath)
end
end
def push_key(%Document{keys: ks} = doc, keypath, value) when is_list(keypath) and is_map(value) do
# Pushing a multi-part key with an inline table value
case push_key_into_table(ks, keypath, value) do
{:ok, ks} ->
doc
|> keys(ks)
|> comment(keypath)
|> open(keypath)
|> to_result()
{:error, :key_exists} ->
key_exists!(keypath)
end
end
def push_key(%Document{keys: ks} = doc, keypath, value) when is_list(keypath) do
# Pushing a multi-part key with a plain value
case push_key_into_table(ks, keypath, value) do
{:ok, ks} ->
opening = Enum.take(keypath, length(keypath) - 1)
doc
|> keys(ks)
|> comment(keypath)
|> open(opening)
|> to_result()
{:error, :key_exists} ->
key_exists!(keypath)
end
end
# Handles pushing a value into a map, arbitrarily deep, and respecting
# the desired behavior of table arrays. Can be used with any map.
#
# NOTE: This is similar to `put_in/3`, but does not raise if a key does
# exist, instead it creates a new table (map) at the level needed, and continues
# pushing the key into this new table
@doc false
def push_key_into_table({:table_array, array}, keypath, value) when is_map(value) do
case array do
[] ->
{:ok, {:table_array, [value]}}
[h | t] when is_map(h) ->
case push_key_into_table(h, keypath, value) do
{:ok, h2} ->
{:ok, {:table_array, [h2 | t]}}
{:error, _} = err ->
err
end
end
end
def push_key_into_table({:table_array, array}, keypath, value) do
# Adding key/value to last table item
case array do
[] ->
case push_key_into_table(%{}, keypath, value) do
{:ok, item} ->
{:ok, {:table_array, [item]}}
{:error, _} = err ->
err
end
[h | t] when is_map(h) ->
case push_key_into_table(h, keypath, value) do
{:ok, h2} ->
{:ok, {:table_array, [h2 | t]}}
{:error, _} = err ->
err
end
end
end
def push_key_into_table(ts, [key], value) do
# Reached final table
case Map.get(ts, key) do
nil ->
{:ok, Map.put(ts, key, value)}
{:table_array, items} when is_list(items) and is_tuple(value) and elem(value, 0) == :table_array ->
# Appending to table array
{:ok, Map.put(ts, key, {:table_array, [%{} | items]})}
{:table_array, items} when is_list(items) and is_map(value) ->
# Pushing into table array
{:ok, Map.put(ts, key, {:table_array, [value | items]})}
exists when is_map(exists) and is_map(value) ->
result =
Enum.reduce(value, exists, fn {k, v}, acc ->
case push_key_into_table(acc, [k], v) do
{:ok, acc2} ->
acc2
{:error, _} = err ->
throw err
end
end)
{:ok, result}
_exists ->
{:error, :key_exists}
end
catch
:throw, {:error, _} = err ->
err
end
def push_key_into_table(ts, [table | keypath], value) do
result =
case Map.get(ts, table) do
nil ->
push_key_into_table(%{}, keypath, value)
child ->
push_key_into_table(child, keypath, value)
end
case result do
{:ok, child} ->
{:ok, Map.put(ts, table, child)}
{:error, _} = err ->
err
end
end
# Raised if the given keypath has already been defined
# and the value being set at that keypath is not an inline
# table (effectively extending the keypath and pushing multiple values),
# or table array (resulting in a new table being pushed into that array)
defp key_exists!(keypath),
do: error!({:key_exists, Enum.join(keypath, ".")})
# Raised due to any other document builder error
defp error!(reason),
do: throw({:error, {:invalid_toml, reason}})
# Set a key at the document root
defp key(%Document{keys: keys} = doc, key, value),
do: %Document{doc | keys: Map.put(keys, key, value)}
# Replace the set of keys in the document
defp keys(%Document{} = doc, keys),
do: %Document{doc | keys: keys}
# Comment the given key, if comments are available on the stack
defp comment(%Document{comments: cs, comment_stack: stack} = doc, key) do
comment =
stack
|> Enum.reverse
|> Enum.join("\n")
if byte_size(comment) > 0 do
%Document{doc | comment_stack: [], comments: Map.put(cs, key, comment)}
else
%Document{doc | comment_stack: []}
end
end
# Open a table at the given key
defp open(%Document{} = doc, key),
do: %Document{doc | open_table: key}
# Close any open table
defp close(%Document{} = doc),
do: %Document{doc | open_table: nil}
# Wrap the document in a ok tuple
defp to_result(%Document{} = doc),
do: {:ok, doc}
end