lib/warpath.ex

defmodule Warpath do
  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  alias Warpath.AccessBuilder
  alias Warpath.Element
  alias Warpath.Element.Path
  alias Warpath.Execution
  alias Warpath.Execution.Env
  alias Warpath.Expression

  @type json :: String.t()

  @type container :: map | struct | list

  @type document :: container | json

  @type selector :: Expression.t() | String.t()

  @type updated_value :: any()

  @type update_fun :: (term() -> updated_value) | (Path.acc(), term() -> updated_value)

  @type opts :: [result_type: :value | :path | :value_path | :path_tokens | :value_path_tokens]

  @doc """
  Remove an item(s) from a nested data structure via the given `selector`.

  If the selector does not evaluate anything, it returns the data structure unchanged.
  > This function rely on `Access` behaviour, that means structs must implement that behaviour to got support.

  ## Examples

      iex> users = %{"john" => %{"age" => 27, "country" => "Brasil"}, "meg" => %{"age" => 23, "country" => "U.K"}}
      ...> Warpath.delete(users, "$..age")
      {:ok, %{"john" => %{"country" => "Brasil"}, "meg" => %{"country" => "U.K"}}}

      iex> numbers = %{"numbers" => [20, 3, 50, 6, 7]}
      ...> Warpath.delete(numbers, "$.numbers[?(@ < 10)]")
      {:ok, %{"numbers" => [20, 50]}}

      iex> numbers = %{"numbers" => [20, 3, 50, 6, 7]}
      ...> Warpath.delete(numbers, "$")
      {:ok, nil}

      iex> users = %{"john" => %{"age" => 27}, "meg" => %{"age" => 23}}
      ...> Warpath.delete(users, "$..city")
      {:ok, %{"john" => %{"age" => 27}, "meg" => %{"age" => 23}}} # Unchanged
  """
  @spec delete(document(), selector()) :: {:ok, container() | nil} | {:error, any}
  def delete(document, selector) do
    decode_run(
      document,
      &execute_change(
        &1,
        selector,
        fn {_tokens, path}, acc -> elem(pop_in(acc, path), 1) end
      )
    )
  end

  @doc """
  Query data for the given expression.

  ## Example
      iex> data_structure = %{"name" => "Warpath"}
      ...> Warpath.query(data_structure, "$.name")
      {:ok, "Warpath"}

      iex> raw_json = ~s/{"name": "Warpath"}/
      ...> Warpath.query(raw_json, "$.name")
      {:ok, "Warpath"}

      iex> #Pass a compiled expression as selector
      ...> {:ok, expression} = Warpath.Expression.compile("$.autobots[0]")
      ...> Warpath.query(%{"autobots" => ["Optimus Prime", "Warpath"]}, expression)
      {:ok, "Optimus Prime"}

  ## Options:
    result_type:
    * `:value` - return the value of evaluated expression - `default`
    * `:path` - return the bracketfiy path string representation of evaluated expression instead of it's value
    * `:value_path` - return both value and bracketify path string.
    * `:path_tokens` - return the path tokens instead of it string representation, see `Warpath.Element.Path`.
    * `:value_path_tokens` - return both value and path tokens.
  """
  @spec query(document, selector(), opts) :: {:ok, any} | {:error, any}
  def query(document, selector, opts \\ [])

  def query(document, selector, opts) when is_binary(document) do
    decode_run(document, &query(&1, selector, opts))
  end

  def query(document, selector, opts) when is_binary(selector) do
    case Expression.compile(selector) do
      {:ok, expression} ->
        query(document, expression, opts)

      {:error, _} = error ->
        error
    end
  end

  def query(document, %Expression{} = expression, opts) do
    result =
      expression
      |> Execution.execution_plan()
      |> Enum.reduce_while(Element.new(document, []), &dispatch/2)

    {:ok, collect(result, opts[:result_type] || :value)}
  end

  @doc """
    The same as query/3, but rise exception if it fail.
  """
  @spec query!(document, selector(), opts) :: any
  def query!(data, selector, opts \\ []) do
    case query(data, selector, opts) do
      {:ok, query_result} -> query_result
      {:error, error} -> raise error
    end
  end

  defp decode_run(json, fun) when is_binary(json) do
    json
    |> Jason.decode()
    |> case do
      {:ok, decoded} ->
        fun.(decoded)

      {:error, exception} ->
        {:error, Warpath.JsonDecodeError.from(exception)}
    end
  end

  defp decode_run(document, fun), do: fun.(document)

  defp dispatch(%Env{operator: operator} = env, elements) when is_list(elements) do
    output = operator.evaluate(elements, [], env)
    {label_of(output), output}
  end

  defp dispatch(%Env{operator: operator} = env, %Element{value: document, path: path}) do
    output = operator.evaluate(document, path, env)
    {label_of(output), output}
  end

  defp label_of([]), do: :halt
  defp label_of(_), do: :cont

  defp collect(elements, opt) when is_list(elements), do: Enum.map(elements, &collect(&1, opt))

  defp collect(%Element{path: path}, :path), do: Path.bracketify(path)
  defp collect(%Element{path: path}, :path_tokens), do: Enum.reverse(path)

  defp collect(%Element{value: member, path: path}, :value_path),
    do: {member, Path.bracketify(path)}

  defp collect(%Element{value: member, path: path}, :value_path_tokens),
    do: {member, Enum.reverse(path)}

  defp collect(%Element{value: member}, _), do: member

  @doc """
    Updates a nested data structure via the given `selector`.

    The `fun` will be called for each item discovered under the given `selector`, the `fun` result will be used to update the data structure.

    If the selector does not evaluate anything, it returns the data structure unchanged.
    > This function rely on `Access` behaviour, that means structs must implement that behaviour to got support.

  ## Examples
      iex> users = %{"john" => %{"age" => 27}, "meg" => %{"age" => 23}}
      ...> Warpath.update(users, "$.john.age", &(&1 + 1))
      {:ok, %{"john" => %{"age" => 28}, "meg" => %{"age" => 23}}}

      iex> numbers = %{"numbers" => [20, 3, 50, 6, 7]}
      ...> Warpath.update(numbers, "$.numbers[?(@ < 10)]", &(&1 * 2))
      {:ok, %{"numbers" => [20, 6, 50, 12, 14]}}

      iex> users = %{"john" => %{"age" => 27}, "meg" => %{"age" => 23}}
      ...> Warpath.update(users, "$.theo.age", &(&1 + 1))
      {:ok, %{"john" => %{"age" => 27}, "meg" => %{"age" => 23}}} # Unchanaged
  """

  @spec update(document(), selector(), update_fun()) ::
          {:ok, container() | updated_value()} | {:error, any}
  def update(document, selector, fun) when is_function(fun, 2) do
    decode_run(
      document,
      &execute_change(
        &1,
        selector,
        fn {tokens, path}, acc ->
          update_in(acc, path, fn node -> fun.(tokens, node) end)
        end
      )
    )
  end

  def update(document, selector, fun) when is_function(fun, 1) do
    update(document, selector, fn _tokens, node -> fun.(node) end)
  end

  defp execute_change(document, selector, reducer) do
    case query(document, selector, result_type: :path_tokens) do
      {:ok, [head | _] = paths} ->
        longest_paths_first =
          if is_list(head) do
            paths
            |> Enum.uniq()
            |> Enum.sort(&>=/2)
          else
            paths
          end

        {:ok,
         longest_paths_first
         |> AccessBuilder.build()
         |> Enum.reduce(document, reducer)}

      {:ok, []} ->
        {:ok, document}

      error ->
        error
    end
  end
end