defmodule Alltid do
@moduledoc """
Alltid offers a simplified approach to editing deeply nested immutable data structures in Elixir.
Inspired by [Immer.js](https://immerjs.github.io/immer/) in JavaScript, Alltid allows a natural
declarative syntax for manipulating deeply nested immutible data structures.
"""
@spec produce(any, {:fn, list(), list()}) :: any
@doc """
Produce an update to `value` using the provided `fun`, a function of arity 1.
Within the provided function, the `<-` operator can be used to simulate assignment
of nested values within the provided argument:
```
iex> Alltid.produce(%{counter: 10}, fn draft ->
...> draft[:counter] <- draft[:counter] + 1
...> draft[:value] <- "some value"
...> end)
%{counter: 11, value: "some value"}
```
Returns a copy of `value` with the changes from `fun` applied.
"""
defmacro produce(value, fun) do
quote do
unquote(rewrite_fn(fun)).(unquote(value))
end
end
defp keypath({{:., _, [Access, :get]}, _, [lhs, rhs]}) do
# extract the path for a bracketed access expression:
# x[:y][:z] => [:x, :y, :z]
keypath(lhs) ++ [key(rhs)]
end
defp keypath({var, _, nil}) do
[var]
end
defp keypath(_) do
[nil]
end
defp key(i) when is_integer(i) do
quote do
Access.at!(unquote(i))
end
end
defp key(i) do
quote do
Access.key(unquote(i))
end
end
defp rewrite([h | t], acc) do
[rewrite(h, acc) | rewrite(t, acc)]
end
defp rewrite(expr = {:<-, _, [lhs, rhs]}, acc) do
# Re-write `lhs <- rhs` expression if `lhs` is composed of `acc`, either alone, or part of a keypath
case keypath(lhs) do
[^acc] ->
# `acc <- value`
{:=, [], [Macro.var(acc, nil), rewrite(rhs, acc)]}
[^acc | path] ->
# `acc[...keypath...] <- value`
{:=, [],
[
Macro.var(acc, nil),
quote do
put_in(unquote(Macro.var(acc, nil)), unquote(path), unquote(rewrite(rhs, acc)))
end
]}
_ ->
# an `x <- y` expression that doesn't include our acc var
expr
end
end
defp rewrite(expr = {op, ln, operands}, acc) do
# Re-write any expression composed of `acc`, either alone, or part of a keypath
case keypath(expr) do
[^acc] ->
expr
[^acc | path] ->
quote do
get_in(unquote(Macro.var(acc, nil)), unquote(path))
end
_ ->
{op, ln, rewrite(operands, acc)}
end
end
defp rewrite(i, _) do
i
end
defp rewrite_fn({:fn, l1, [{:->, l2, [[{acc, l3, nil}], expr]}]}) do
# Deconstruct the anonymous function definition: `fn acc -> [expr] end`, where `expr` is an
# expression or block of expressions.
#
# Rewrite the function as follows:
# - expressions matching "param[keypath] <- expr" are re-written to "acc = put_in(acc, path, expr)"
# - expressions matching "param[keypath]" are re-written to "get_in(acc, path)", with list access handling added
# - `acc` is returned at the end
code =
case expr do
{:__block__, _, expressions} -> expressions
expression -> [expression]
end
{:fn, l1,
[
{:->, l2,
[[{acc, l3, nil}], {:__block__, [], rewrite(code ++ [Macro.var(acc, nil)], acc)}]}
]}
end
end