lib/pathex.ex

defmodule Pathex do
  @moduledoc """
  This module contains functions and macros to be used with `Pathex` and i

  To use Pathex just insert to your context. You can import Pathex in module body or even in function body.
  ```elixir
  require Pathex
  import Pathex, only: [path: 1, path: 2, "~>": 2, ...]
  ```

  Or you can use `use`
  ```elixir
  defmodule MyModule do

    # `default_mod` option is optional
    # when no mod is specified, `:naive` is selected
    use Pathex, default_mod: :json

    ...
  end
  ```
  This will import all operatiors and `path` macro

  Any macro here belongs to one of three categories:
  1. Macro which creates path closure (only `path/2`)
  2. Macro which uses path closure as path (`over/3`, `set/3`, `view/2`, ...)
  3. Macro which creates path composition (`~>/2`, `|||/2`, ...)
  """

  alias Pathex.Builder
  alias Pathex.Combination
  alias Pathex.Common
  alias Pathex.Operations
  alias Pathex.QuotedParser

  @typedoc """
  Function which is passed to path-closure as second element in args tuple
  """
  @type inner_func(output) :: (any() -> result(output))

  @type inspect_args :: any()
  @type update_args(input, output) :: {input, inner_func(output)}
  @type force_update_args(input, output) :: {input, inner_func(output), any()}

  @typedoc "This depends on the modifier"
  @type pathex_compatible_structure :: map() | list() | Keyword.t() | tuple()

  @typedoc "Value returned by non-bang path call"
  @type result(inner) :: {:ok, inner} | :error | :delete_me

  @typedoc "Also known as [path-closure](path.md)"
  @type t :: t(pathex_compatible_structure(), any())

  @typedoc "Also known as [path-closure](path.md)"
  @type t(input, output) ::
          (op_name(),
           force_update_args(input, output)
           | update_args(input, output)
           | inspect_args() ->
             result(output | input))

  @typedoc "More about [modifiers](modifiers.md)"
  @type mod :: :map | :json | :naive

  @typep op_name :: Operations.name()

  @doc """
  Easy and convenient way to add pathex to your module.

  You can specify modifier
  ```elixir
  use Pathex, default_mod: :json
  ```

  Or just use it with default `:naive` modifier
  ```elixir
  use Pathex
  ```
  """
  @doc export: true
  defmacro __using__(opts) do
    case Keyword.get(opts, :default_mod, :naive) do
      :naive ->
        quote do
          require Pathex
          import Pathex, only: [path: 1, path: 2, ~>: 2, &&&: 2, |||: 2, alongside: 1]
        end

      mod when mod in ~w[json map]a ->
        quote do
          require Pathex
          import Pathex, only: [path: 1, path: 2, ~>: 2, &&&: 2, |||: 2, alongside: 1]

          @pathex_default_mod unquote(mod)
        end

      _wrong_mod ->
        raise ArgumentError, "Pathex only works with navie, json and map mods"
    end
  end

  @doc """
  Applies `func` to the item under the `path` in `struct`
  and returns modified structure. Works like `Map.update!/3` but doesn't raise.

  Example:
      iex> index = 1
      iex> inc = fn x -> x + 1 end
      iex> {:ok, [0, %{x: 9}]} = over [0, %{x: 8}], path(index / :x), inc
      iex> p = path "hey" / 0
      iex> {:ok, %{"hey" => [2, [2]]}} = over %{"hey" => [1, [2]]}, p, inc

  > Note:
  > Exceptions from passed function left unhandled
      iex> over(%{1 => "x"}, path(1), fn x -> x + 1 end)
      ** (ArithmeticError) bad argument in arithmetic expression
  """
  @doc export: true
  defmacro over(struct, path, func) do
    gen(path, :update, [struct, wrap_ok(func)], __CALLER__)
  end

  @doc """
  Applies the `func` to the item under `path` in `struct` and returns modified structure.
  Works like `Map.update!/3`.

  Example:
      iex> x = 1
      iex> inc = fn x -> x + 1 end
      iex> [0, %{x: 9}] = over! [0, %{x: 8}], path(x / :x), inc
      iex> p = path "hey" / 0
      iex> %{"hey" => [2, [2]]} = over! %{"hey" => [1, [2]]}, p, inc
  """
  @doc export: true
  defmacro over!(struct, path, func) do
    path
    |> gen(:update, [struct, wrap_ok(func)], __CALLER__)
    |> bang(struct, path)
  end

  @doc """
  Sets `value` under `path` in `structure`. Think of it like `Map.put/3`.

  Example:
      iex> x = 1
      iex> {:ok, [0, %{x: 123}]} = set [0, %{x: 8}], path(x / :x), 123
      iex> p = path "hey" / 0
      iex> {:ok, %{"hey" => [123, [2]]}} = set %{"hey" => [1, [2]]}, p, 123
  """
  @doc export: true
  defmacro set(struct, path, value) do
    gen(path, :update, [struct, quote(do: fn _ -> {:ok, unquote(value)} end)], __CALLER__)
  end

  @doc """
  Sets the `value` under `path` in `struct`. Think of it like `Map.put/3`.

  Example:
      iex> x = 1
      iex> [0, %{x: 123}] = set! [0, %{x: 8}], path(x / :x), 123
      iex> p = path "hey" / 0
      iex> %{"hey" => [123, [2]]} = set! %{"hey" => [1, [2]]}, p, 123
  """
  @doc export: true
  defmacro set!(struct, path, value) do
    path
    |> gen(:update, [struct, quote(do: fn _ -> {:ok, unquote(value)} end)], __CALLER__)
    |> bang(struct, path)
  end

  @doc """
  Sets the `value` under `path` in `struct`.

  If the path does not exist it creates the path favouring maps
  when structure is unknown.

  Example:
      iex> x = 1
      iex> {:ok, [0, %{x: 123}]} = force_set [0, %{x: 8}], path(x / :x), 123
      iex> p = path "hey" / 0
      iex> {:ok, %{"hey" => %{0 => 1}}} = force_set %{}, p, 1

  If the item in path doesn't have the right type, it returns `:error`.

  Example:
      iex> p = path "hey" / "you"
      iex> :error = force_set %{"hey" => {1, 2}}, p, "value"
  """
  @doc export: true
  defmacro force_set(struct, path, value) do
    gen(
      path,
      :force_update,
      [struct, quote(do: fn _ -> {:ok, unquote(value)} end), value],
      __CALLER__
    )
  end

  @doc """
  Sets the `value` under `path` in `struct`.

  If the path does not exist it creates the path favouring maps
  when structure is unknown.

  Example:
      iex> x = 1
      iex> [0, %{x: 123}] = force_set! [0, %{x: 8}], path(x / :x), 123
      iex> p = path "hey" / 0
      iex> %{"hey" => %{0 => 1}} = force_set! %{}, p, 1

  If the item in path doesn't have the right type, it raises.

  Example:
      iex> p = path "hey" / "you"
      iex> force_set! %{"hey" => {1, 2}}, p, "value"
      ** (Pathex.Error) Type mismatch in structure
  """
  @doc export: true
  defmacro force_set!(struct, path, value) do
    path
    |> gen(
      :force_update,
      [struct, quote(do: fn _ -> {:ok, unquote(value)} end), value],
      __CALLER__
    )
    |> bang(struct, path, "Type mismatch in structure")
  end

  @doc """
  Applies `func` under `path` of `struct`.

  If the path does not exist it creates the path favouring maps
  when structure is unknown and inserts default value.

  Example:
      iex> x = 1
      iex> {:ok, [0, %{x: {:xxx, 8}}]} = force_over([0, %{x: 8}], path(x / :x), & {:xxx, &1}, 123)
      iex> p = path "hey" / 0
      iex> {:ok, %{"hey" => %{0 => 1}}} = force_over(%{}, p, fn x -> x + 1 end, 1)

  If the item in path doesn't have the right type, it returns `:error`.

  Example:
      iex> p = path "hey" / "you"
      iex> :error = force_over %{"hey" => {1, 2}}, p, fn x -> x end, "value"
  """
  @doc export: true
  defmacro force_over(struct, path, func, value \\ nil) do
    gen(path, :force_update, [struct, wrap_ok(func), value], __CALLER__)
  end

  @doc """
  Applies `func` under `path` of `struct`.

  If the path does not exist it creates the path favouring maps
  when structure is unknown and inserts default value.

  Example:
      iex> x = 1
      iex> [0, %{x: {:xxx, 8}}] = force_over!([0, %{x: 8}], path(x / :x), & {:xxx, &1}, 123)
      iex> p = path "hey" / 0
      iex> %{"hey" => %{0 => 1}} = force_over!(%{}, p, fn x -> x + 1 end, 1)

  If the item in path doesn't have the right type, it raises.

  Example:
      iex> p = path "hey" / "you"
      iex> force_over! %{"hey" => {1, 2}}, p, fn x -> x end, "value"
      ** (Pathex.Error) Type mismatch in structure
  """
  @doc export: true
  defmacro force_over!(struct, path, func, value \\ nil) do
    path
    |> gen(:force_update, [struct, wrap_ok(func), value], __CALLER__)
    |> bang(struct, path, "Type mismatch in structure")
  end

  @doc """
  Applies `func` under `path` in `struct` and returns result of this `func`.

  Example:
      iex> x = 1
      iex> {:ok, 9} = at [0, %{x: 8}], path(x / :x), fn x -> x + 1 end
      iex> p = path "hey" / 0
      iex> {:ok, {:here, 9}} = at(%{"hey" => {9, -9}}, p, & {:here, &1})
  """
  @doc export: true
  defmacro at(struct, path, func) do
    gen(path, :view, [struct, wrap_ok(func)], __CALLER__)
  end

  @doc """
  Applies `func` under `path` in `struct` and returns result of this `func`.
  Raises if path is not found.

  Example:
      iex> x = 1
      iex> 9 = at! [0, %{x: 8}], path(x / :x), fn x -> x + 1 end
      iex> p = path "hey" / 0
      iex> {:here, 9} = at!(%{"hey" => {9, -9}}, p, & {:here, &1})
  """
  @doc export: true
  defmacro at!(struct, path, func) do
    path
    |> gen(:view, [struct, wrap_ok(func)], __CALLER__)
    |> bang(struct, path)
  end

  @doc """
  Gets the value under `path` in `struct`.

  Example:
      iex> x = 1
      iex> {:ok, 8} = view [0, %{x: 8}], path(x / :x)
      iex> p = path "hey" / 0
      iex> {:ok, 9} = view %{"hey" => {9, -9}}, p
  """
  @doc export: true
  defmacro view(struct, path) do
    gen(path, :view, [struct, quote(do: fn x -> {:ok, x} end)], __CALLER__)
  end

  @doc """
  Gets the value under `path` in `struct`. Raises if `path` not found.

  Example:
      iex> x = 1
      iex> 8 = view! [0, %{x: 8}], path(x / :x)
      iex> p = path "hey" / 0
      iex> 9 = view! %{"hey" => {9, -9}}, p
  """
  @doc export: true
  defmacro view!(struct, path) do
    path
    |> gen(:view, [struct, quote(do: fn x -> {:ok, x} end)], __CALLER__)
    |> bang(struct, path)
  end

  @doc """
  Gets the value under `path` in `struct` or returns `default` when `path` is not present.

  Example:
      iex> x = 1
      iex> 8 = get([0, %{x: 8}], path(x / :x))
      iex> p = path "hey" / "you"
      iex> nil = get(%{"hey" => [x: 1]}, p)
      iex> :default = get(%{"hey" => [x: 1]}, p, :default)
  """
  @doc export: true
  defmacro get(struct, path, default \\ nil) do
    res = gen(path, :view, [struct, quote(do: fn x -> {:ok, x} end)], __CALLER__)

    quote do
      case unquote(res) do
        {:ok, value} -> value
        :error -> unquote(default)
      end
    end
    |> Common.set_generated()
  end

  @doc """
  Gets the value under `path` in `struct` or returns default value if not found.

  Example:
      iex> x = 1
      iex> true = exists?([0, %{x: 8}], path(x / :x))
      iex> p = path "hey" / "you"
      iex> false = exists?(%{"hey" => [x: 1]}, p)
  """
  @doc export: true
  defmacro exists?(struct, path) do
    res = gen(path, :view, [struct, quote(do: fn _ -> true end)], __CALLER__)

    quote do
      with :error <- unquote(res) do
        false
      end
    end
    |> Common.set_generated()
  end

  @doc """
  Deletes value under `path` in `struct`.

  Example:
      iex> x = 1
      iex> {:ok, [0, %{}]} = delete([0, %{x: 8}], path(x / :x))
      iex> :error = delete([0, %{x: 8}], path(1 / :y))
  """
  @doc export: true
  defmacro delete(struct, path) do
    path
    |> gen(:delete, [struct, quote(do: fn _ -> :delete_me end)], __CALLER__)
    |> wrap_delete_me()
  end

  @doc """
  Deletes value under `path` in `struct` or raises if value is not found.

  Example:
      iex> x = 1
      iex> [0, %{}] = delete!([0, %{x: 8}], path(x / :x))
  """
  @doc export: true
  defmacro delete!(struct, path) do
    path
    |> gen(:delete, [struct, quote(do: fn _ -> :delete_me end)], __CALLER__)
    |> wrap_delete_me()
    |> bang(struct, path)
  end

  defp wrap_delete_me(call) do
    quote do
      with :delete_me <- unquote(call) do
        :error
      end
    end
  end

  @doc """
  Macro which gets value in the structure and deletes it.
  Note that current implementation of this function performs double lookup.

  Example:
      iex> {:ok, {1, [2, 3]}} = pop([1, 2, 3], path(0))
  """
  @doc export: true
  defmacro pop(struct, path) do
    view = gen(path, :view, [struct, quote(do: fn x -> {:ok, x} end)], __CALLER__)
    delete = gen(path, :delete, [struct, quote(do: fn _ -> :delete_me end)], __CALLER__)

    quote do
      with(
        {:ok, value} <- unquote(view),
        {:ok, structure} <- unquote(delete)
      ) do
        {:ok, {value, structure}}
      end
    end
  end

  @doc """
  Gets value under `path` in `struct` and then deletes it.
  Note that current implementation of this function performs double lookup.

  Example:
      iex> {1, [2, 3]} = pop!([1, 2, 3], path(0))
  """
  @doc export: true
  defmacro pop!(struct, path) do
    view =
      path
      |> gen(:view, [struct, quote(do: fn x -> {:ok, x} end)], __CALLER__)
      |> bang(struct, path)

    delete =
      path
      |> gen(:delete, [struct, quote(do: fn _ -> :delete_me end)], __CALLER__)
      |> wrap_delete_me()
      |> bang(struct, path)

    quote do
      value = unquote(view)
      structure = unquote(delete)
      {value, structure}
    end
  end

  @doc """
  Creates path from `quoted` ast. Paths look like unix fs path and consist of
  elements separated from each other with `/`. See 

  For example:
      iex> x = 1
      iex> mypath = path 1 / :atom / "string" / {"tuple?"} / x
      iex> structure = [0, [atom: %{"string" => %{{"tuple?"} => %{1 => 2}}}]]
      iex> {:ok, 2} = view structure, mypath

  Default [modifier](modifiers.md) of this `path/2` is `:naive` which means that
  * Every variable is treated as index or key to tuple, list, map and keyword
  * Every atom is treated as key to map or keyword
  * Every integer is treated as index to tuple, list or key to map
  * Every other data type is treated as key to map

  > Note:  
  > `-1` allows data to be prepended to the list
      iex> x = -1
      iex> p1 = path(-1)
      iex> p2 = path(x)
      iex> {:ok, [1, 2]} = force_set([2], p1, 1)
      iex> {:ok, [1, 2]} = force_set([2], p2, 1)
  """
  @doc export: true
  defmacro path(quoted, mod \\ nil) do
    mod = get_mod(mod, __CALLER__)
    {binds, combination} = QuotedParser.parse(quoted, __CALLER__, mod)

    if Macro.Env.in_match?(__CALLER__) do

    else

    end

    combination
    |> assert_combination_length(__CALLER__)
    |> Builder.build(Operations.builders_for_combination(combination))
    |> Common.set_generated()
    |> prepend_binds(binds)
  end

  @doc """
  Creates composition of two paths similar to concatenating them together.  
  This means that `a ~> b` path-closure applies `a` and only if it returns `{:ok, something}`
  it applies `b` to `something`

  Example:
      iex> p1 = path :x / :y
      iex> p2 = path :a / :b
      iex> composed_path = p1 ~> p2
      iex> {:ok, 1} = view %{x: [y: [a: [a: 0, b: 1]]]}, composed_path
  """
  @doc export: true
  defmacro a ~> b, do: do_concat(a, b, __CALLER__)

  @doc """
  The same as `Pathex.~>/2` for those who do not like operators

  Example:
      iex> p1 = path :x / :y
      iex> p2 = path :a / :b
      iex> composed_path = concat(p1, p2)
      iex> {:ok, 1} = view %{x: [y: [a: [a: 0, b: 1]]]}, composed_path
  """
  @doc export: true
  defmacro concat(a, b), do: do_concat(a, b, __CALLER__)

  defp do_concat(a, b, caller) do
    {:~>, [], [a, b]}
    |> QuotedParser.parse_composition(:~>)
    |> Builder.build_composition(:~>, caller)
    |> Common.set_generated()
  end

  @doc """
  Creates composition of two paths which has some inspiration from logical `and`.  
  This means that `a &&& b` path-closure tries to apply `a` and only if it returns `{:ok, something}`, tries
  apply `b` and if `b` returns **exactly the same** as `a` does, the `a &&& b` returns `{:ok, something}`

  Example:
      iex> p1 = path :x / :y
      iex> p2 = path :a / :b
      iex> ap = p1 &&& p2
      iex> {:ok, 1} = view %{x: %{y: 1}, a: [b: 1]}, ap
      iex> :error = view %{x: %{y: 1}, a: [b: 2]}, ap
      iex> {:ok, %{x: %{y: 2}, a: [b: 2]}} = set %{x: %{y: 1}, a: [b: 1]}, ap, 2
      iex> {:ok, %{x: %{y: 2}, a: %{b: 2}}} = force_set %{}, ap, 2
  """
  @doc export: true
  defmacro a &&& b do
    {:&&&, [], [a, b]}
    |> QuotedParser.parse_composition(:&&&)
    |> Builder.build_composition(:&&&, __CALLER__)
    |> Common.set_generated()
  end

  @doc """
  Creates composition of two paths which has some inspiration from logical `or`.  
  This means that `a ||| b` path-closure tries to apply `a` and only if it returns `:error`, tries
  apply `b`

  Example:
      iex> p1 = path :x / :y
      iex> p2 = path :a / :b
      iex> op = p1 ||| p2
      iex> {:ok, 1} = view %{x: %{y: 1}, a: [b: 2]}, op
      iex> {:ok, 2} = view %{x: 1, a: [b: 2]}, op
      iex> {:ok, %{x: %{y: 2}, a: [b: 1]}} = set %{x: %{y: 1}, a: [b: 1]}, op, 2
      iex> {:ok, %{x: %{y: 2}}} = force_set %{}, op, 2
      iex> {:ok, %{x: %{}, a: [b: 1]}} = force_set %{x: %{y: 1}, a: [b: 1]}, op, 2
  """
  @doc export: true
  defmacro a ||| b do
    {:|||, [], [a, b]}
    |> QuotedParser.parse_composition(:|||)
    |> Builder.build_composition(:|||, __CALLER__)
    |> Common.set_generated()
  end

  @doc """
  This macro creates compositions of paths which work along with each other

  Think of `alongside([path1, path2, path3])` as `path1 &&& path2 &&& path3`
  The only difference is that for viewing alongside returns list of variables

  Example:
      iex> pa = alongside [path(:x), path(:y)]
      iex> {:ok, [1, 2]} = view(%{x: 1, y: 2}, pa)
      iex> {:ok, %{x: 3, y: 3}} = set(%{x: 1, y: 2}, pa, 3)
      iex> :error = set(%{x: 1}, pa, 3)
      iex> {:ok, %{x: 1, y: 1}} = force_set(%{}, pa, 1)
  """
  @doc export: true
  defmacro alongside(list) do
    list
    |> Builder.build_composition(:alongside, __CALLER__)
    |> Common.set_generated()
  end

  @doc """
  Inspect the given path-closure and returns string which corresponds to given path-closure

  Example:
      iex> index = 1
      iex> p = path(:x) ~> path(:y / index) &&& path(-1)
      iex> Pathex.inspect(p)
      "path(:x) ~> path(:y / 1) &&& path(-1)"
  """
  @spec inspect(Pathex.t()) :: iodata()
  @doc export: true
  def inspect(path_closure) when is_function(path_closure, 2) do
    Macro.to_string path_closure.(:inspect, [])
  end

  # Helpers

  # Helper for generating code for path operation
  # Special case for inline paths
  defp gen({:path, _, [path | tail]}, op, args, caller) do
    mod =
      tail
      |> List.first()
      |> get_mod(caller)

    path_func = build_only(path, op, caller, mod)

    quote generated: true do
      unquote(path_func).(unquote_splicing(args))
    end
    |> Common.set_generated()
  end

  # Case for not inlined paths
  defp gen(path, op, args, _caller) do
    quote generated: true do
      unquote(path).(unquote(op), {unquote_splicing(args)})
    end
    |> Common.set_generated()
  end

  defp wrap_ok(func) do
    quote do
      fn x -> {:ok, unquote(func).(x)} end
    end
  end

  # Helper for generating raising functions
  @spec bang(Macro.t(), Operations.t(), Macro.t(), binary()) :: Macro.t()
  defp bang(quoted, structure, path, err_str \\ "Couldn't find element") do
    quote generated: true do
      case unquote(quoted) do
        {:ok, value} ->
          value

        :error ->
          raise Pathex.Error,
            message: unquote(err_str),
            path: unquote(path),
            structure: unquote(structure)
      end
    end
  end

  defp get_mod(nil, %Macro.Env{module: nil}), do: :naive

  defp get_mod(nil, %Macro.Env{module: module}) do
    Module.get_attribute(module, :pathex_default_mod) || :naive
  end

  defp get_mod(mod, _), do: detect_mod(mod)

  # Helper for detecting mod
  @spec detect_mod(mod() | charlist()) :: mod() | no_return()
  defp detect_mod(mod) when mod in ~w[naive map json]a, do: mod
  defp detect_mod(str) when is_binary(str), do: detect_mod('#{str}')
  defp detect_mod('json'), do: :json
  defp detect_mod('map'), do: :map
  defp detect_mod('naive'), do: :naive
  defp detect_mod(_), do: raise("Can't have this modifier set")

  # Builds only one clause of a path
  defp build_only(path, opname, caller, mod) do
    {binds, combination} =
      path
      |> fetch_args(caller)
      |> QuotedParser.parse(caller, mod)

    %{^opname => builder} = Operations.builders_for_combination(combination)

    combination
    |> Builder.build_only(builder)
    |> prepend_binds(binds)
  end

  defp fetch_args(path, caller) do
    case Macro.prewalk(path, &Macro.expand(&1, caller)) do
      {{:., _, [__MODULE__, :path]}, _, args} ->
        args

      {:path, meta, args} = full ->
        case Keyword.fetch(meta, :import) do
          {:ok, __MODULE__} ->
            args

          _ ->
            full
        end

      args ->
        args
    end
  end

  # This function raises warning if combination will lead to very big closure
  @maximum_combination_size 256
  defp assert_combination_length(combination, env) do
    size = Combination.size(combination)

    if size > @maximum_combination_size do
      {func, arity} = env.function || {:nofunc, 0}
      stacktrace = [{env.module, func, arity, [file: '#{env.file}', line: env.line]}]

      """
      This path will generate too many clauses, and therefore will slow down
      the compilation and increase amount of generated code. Current
      combination has #{size} clauses while suggested amount is #{@maximum_combination_size}

      It would be better to split this closure in different paths with `Pathex.~>/2`
      Or change the modifier to one which generates less code: `:map` or `:json`
      """
      |> IO.warn(stacktrace)
    end

    combination
  end

  defp prepend_binds(combination, binds) do
    quote do
      unquote_splicing(binds)
      unquote(combination)
    end
  end
end