Packages

Parse HOCON configuration files in Elixir following the HOCON specifications.

Current section

Files

Jump to
hocon lib hocon document.ex
Raw

lib/hocon/document.ex

defmodule Hocon.Document do
@moduledoc """
This module represents the necessary functions for the creation of the final configuration. The configuration
is built up with `put/3` function and as the final step the `convert` function is called, which resolves the
substitutions to its current values in the current configuration object.
"""
alias Hocon.Document
defstruct root: %{}
def new() do
%Document{}
end
def put(doc, key, value, tokens \\ [])
def put(doc, key, %Document{root: value}, tokens) do
put(doc, key, value, tokens)
end
def put(%Document{root: root}, key, value, tokens) do
path = key
|> String.split(".")
|> Enum.map(fn str -> String.trim(str) end)
|> Enum.filter(fn str -> str != nil end)
{rest, root} = put_path(root, path, value, [], tokens)
{rest, %Document{root: root}}
end
defp put_path(root, [key], nil, _visited, tokens) do
with {_, result} <- Map.pop(root, key) do
{tokens, result}
end
end
defp put_path(root, [key], value, visited, tokens) do
case Map.get(root, key) do
nil -> {tokens, Map.put(root, key, value)}
other ->
# Here we need to check for self references.
# In this case we have to look forward for the current value.
abs_path = Enum.reverse([key|visited]) |> Enum.join(".")
value = resolve_possible_self_references(root, abs_path, value)
# If we have now a list, then check if another list follows. In this case we merging both arrays
{tokens, value} = case is_list(value) do
true -> Hocon.Parser.try_concat_array(tokens, value)
false -> {tokens, value}
end
{tokens, merge(root, key, other, value)}
end
end
defp put_path(root, [head|tail], value, visited, tokens) do
{rest, value} = put_path(Map.get(root, head, %{}), tail, value, [head|visited], tokens)
{rest, Map.put(root, head, value)}
end
##
# Resolving the current value in cases of self references.
# abs_path: the current absolute key path is used to identify a self reference substitution
#
##
defp resolve_possible_self_references(root, abs_path, {:unquoted_string, value}) do
case find_substitutions(value) do
[] -> value # case: no subsitutions
[found] when found == value -> # case: exact one substitution
{:ok, value} = resolve_possible_self_reference(root, abs_path, found)
value
found -> # case: multiple substitution within a string like "${foo} ${bar}"
value = found
|> Enum.map(fn subs -> {subs, resolve_possible_self_reference(root, abs_path, subs)} end)
|> Enum.filter(fn {key, {:ok, value}} -> was_resolved(key, value) end) # only replaces what we have found
|> Enum.reduce(value, fn {key, {:ok, value}}, result -> String.replace(result, key, value |> fetch_value() |> to_string()) end)
{:unquoted_string, value}
end
end
defp resolve_possible_self_references(_root, _abs_path, value) do
value
end
defp was_resolved(key, {:unquoted_string, value}), do: value != key
defp was_resolved(_, _), do: true
defp fetch_value({:unquoted_string, value}), do: value
defp fetch_value(value), do: value
defp resolve_possible_self_reference(root, abs_path, subs) do
{kind, subs_path} = get_path(subs)
case String.starts_with?(subs_path, abs_path) do # is this a good idea?
false -> {:ok, {:unquoted_string, subs}} # case: not self reference - keep it unmodified
true -> root |> get_raw(subs_path) |> resolve_optional_self_reference(kind) # case: self reference - get current value
end
end
defp resolve_optional_self_reference({:ok, _} = result, _) do
result
end
defp resolve_optional_self_reference({:not_found, path} = result, :mandatory) do
throw result
end
defp resolve_optional_self_reference({:not_found, _}, :optional) do
{:ok, ""}
end
defp find_substitutions(value) do
Regex.scan(~r/\$\{.*?\}/, value)
|> Enum.map(fn [subs] -> subs end)
end
def merge(root, key, %{} = original, %{} = value) do
Map.put(root, key, Map.merge(original, value))
end
def merge(root, key, _original, value) do
Map.put(root, key, value)
end
def merge(%Document{root: this}, %Document{root: that}) do
%Document{root: Map.merge(this, that)}
end
@doc """
Converts the document into a map by
* converting numerically indexed maps into arrays
* resolving substitutions to it's values
## Example
iex> doc = %Document{root: %{"employee" => %{"firstname" => "Michael", "lastname" => "Maier"}, "fullname" => "${employee.firstname} ${employee.lastname}"}}
iex> Document.convert(doc)
%{
"employee" => %{"firstname" => "Michael", "lastname" => "Maier"},
"fullname" => "Michael Maier"
}
"""
def convert(%Document{root: this}, opts \\ []) do
convert_map(this, this, [], opts)
end
defp convert_map(original, current, visited, opts) do
Map.to_list(current)
|> Enum.map(fn {key, value} -> convert_numerically_indexed(key, value, opts) end)
|> Enum.map(fn
{key, {:unquoted_string, value}} -> replace_all_substitutions(original, key, value, visited)
other -> other
end)
|> Enum.map(fn {key, value} -> convert_nested_maps(original, key, value, push_key(key, visited), opts) end)
|> Enum.map(fn {key, value} -> resolve_unquoted_strings_in_arrays(original, key, value, visited) end)
|> Enum.into(%{})
end
defp resolve_unquoted_strings_in_arrays(original, key, value, visited) when is_list(value) do
value = Enum.map(value, fn
{:unquoted_string, subs} -> with {_, value} <- replace_all_substitutions(original, key, subs, visited) do
value
end
other -> other
end)
{key, value}
end
defp resolve_unquoted_strings_in_arrays(_original, key, value, _visited) do
{key, value}
end
defp convert_nested_maps(original, key, value, visited, opts) when is_map(value) do
{key, convert_map(original, value, visited, opts)}
end
defp convert_nested_maps(_original, key, value, _visited, _opts) do
{key, value}
end
def push_key(key, []) do
[key]
end
def push_key(key, [head|_] = stack) do
[head <> "." <> key|stack]
end
defp replace_all_substitutions(original, key, value, visited) do
with {:ok, value} <- resolve_substitutions(original, value, visited) do
{key, value}
end
end
defp convert_numerically_indexed(key, value, opts) when is_map(value) do
case Keyword.get(opts, :convert_numerically_indexed, false) do
true ->
case convert_to_array(value, opts) do
{:converted, array } -> {key, array}
_ -> {key, value}
end
false -> {key, value}
end
end
defp convert_numerically_indexed(key, value, _opts) do
{key, value}
end
defp convert_to_array(root, opts) do
is_strict = Keyword.get(opts, :strict_conversion, true)
convertable = case is_strict do
true -> root
|> Map.keys()
|> Enum.all?(fn key -> String.match?(key, ~r/^\d$/) end)
false -> root
|> Map.keys()
|> Enum.any?(fn key -> String.match?(key, ~r/^\d$/) end)
end
case convertable do
true -> {:converted, to_array(root)}
false -> :not_converted
end
end
defp to_array(map) do
map
|> Map.to_list()
|> Enum.filter(fn {key, _} -> String.match?(key, ~r/^\d$/) end)
|> Enum.map(fn {key, value} -> {String.to_integer(key), value} end)
|> Enum.sort(fn a, b -> elem(a, 0) < elem(b, 0) end)
|> Enum.map(fn {_,value} -> value end)
end
defp get(root, keypath, visited) when is_binary(keypath) do
keypath = keypath
|> String.split(".")
|> Enum.map(fn str -> String.trim(str) end)
get(root, root, keypath, visited)
end
defp get(root, {:unquoted_string, value}, [], visited) do
resolve_substitutions(root, value, visited)
end
defp get(_root, nil, keypath, visited) do
{:not_found, (Enum.reverse(visited) ++ keypath) |> Enum.join(".")}
end
defp get(_root, value, [], _visited) do
{:ok, value}
end
defp get(root, object, [key|rest], visited) when is_map(object) do
get(root, Map.get(object, key), rest, push_key(key, visited))
end
def get_raw(root, keypath, visited \\ []) when is_binary(keypath) do
keypath = keypath
|> String.split(".")
|> Enum.map(fn str -> String.trim(str) end)
get_raw(root, root, keypath, visited)
end
def get_raw(_root, nil, keypath, visited) do
{:not_found, (Enum.reverse(visited) ++ keypath) |> Enum.join(".")}
end
def get_raw(_root, value, [], _visited) do
{:ok, value}
end
def get_raw(root, object, [key|rest], visited) when is_map(object) do
get_raw(root, Map.get(object, key), rest, push_key(key, visited))
end
##
# resolve the substitutions in the `value` one after the other
##
defp resolve_substitutions(root, value, visited) do
with [substituions] <- Regex.run(~r/\$\{.*?\}/, value), ## find first substitution string
{kind, path} <- get_path(substituions), ## extracts content
:ok <- check_circle(path, visited), ## check for circles
{:ok, new_value} <- get_or_fetch_env(get(root, path, visited), path, kind) do ## fetch value or try to find an environment variable
case value == substituions do
true -> {:ok, new_value} ## Single substitution: ${foo} == ${foo}
false -> ## Multiple substitutions within a string: ${foo} ${bar} == ${foo}
value = String.replace(value, substituions, to_string(new_value))
resolve_substitutions(root, value, visited)
end
else
nil -> {:ok, value}
{:not_found, path} -> throw {:not_found, path}
{:circle_detected, path} -> throw {:circle_detected, path}
end
end
def get_or_fetch_env({:ok, _} = result, _path, _kind), do: result
def get_or_fetch_env({:not_found, _}, path, :mandatory) do
case System.fetch_env(path) do
{:ok, result} -> {:ok, result}
_ -> {:not_found, path}
end
end
def get_or_fetch_env({:not_found, _}, path, :optional) do
case System.fetch_env(path) do
{:ok, result} -> {:ok, result}
_ -> {:ok, ""}
end
end
##
# Checks for circles.
# Example:
#
# bar : ${foo}
# foo : ${bar}
#
# creates a visited path list:
# ["foo.bar", "foo"]
#
# in case of resolving ${foo} again, we found this path in the visited path list:
# ["foo.bar", "foo"]
#
# and this is a circle
##
defp check_circle(_path, []) do
:ok
end
defp check_circle(path, visited) do
visited = visited |> Enum.reverse() |> Enum.any?(fn visiting -> path == visiting end)
case visited do
false -> :ok
true -> {:circle_detected, path}
end
end
##
# transform `${key.path}` to `key.path`
##
defp get_path(<<"${?", rest::bits>>) do
{:optional, String.slice(rest, 0, String.length(rest) - 1)}
end
defp get_path(<<"${", rest::bits>>) do
{:mandatory, String.slice(rest, 0, String.length(rest) - 1)}
end
# coveralls-ignore-start
defp get_path(other) do
throw {:unknown, other}
end
# coveralls-ignore-stop
end