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