Skip to main content

src/gleamson@patch.erl

-module(gleamson@patch).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/gleamson/patch.gleam").
-export([apply/2, diff/2, to_json/1, decoder/0]).
-export_type([operation/0, patch_error/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " JSON Patch (RFC 6902): describe and apply changes to a `gleamson.Json`\n"
    " document, and compute the difference between two documents.\n"
    "\n"
    " A patch is a list of `Operation`s applied in order and atomically — if any\n"
    " operation fails, `apply` returns an error and the original document is\n"
    " left untouched. Paths are JSON Pointers (RFC 6901).\n"
    "\n"
    " ```gleam\n"
    " import gleamson\n"
    " import gleamson/patch.{Add, Replace}\n"
    "\n"
    " let assert Ok(doc) = gleamson.parse(\"{\\\"a\\\":1,\\\"b\\\":[10]}\")\n"
    " let assert Ok(out) =\n"
    "   patch.apply(doc, [Replace(\"/a\", gleamson.Int(2)), Add(\"/b/-\", gleamson.Int(20))])\n"
    " // out == {\"a\":2,\"b\":[10,20]}\n"
    " ```\n"
).

-type operation() :: {add, binary(), gleamson:json()} |
    {remove, binary()} |
    {replace, binary(), gleamson:json()} |
    {move, binary(), binary()} |
    {copy, binary(), binary()} |
    {test, binary(), gleamson:json()}.

-type patch_error() :: {path_not_found, binary()} |
    {invalid_path, binary()} |
    {test_failed, binary(), gleamson:json(), gleamson:json()}.

-file("src/gleamson/patch.gleam", 474).
-spec element_at(list(gleamson:json()), integer(), binary()) -> {ok,
        gleamson:json()} |
    {error, patch_error()}.
element_at(Items, I, Token) ->
    {_, Rest} = gleam@list:split(Items, I),
    case Rest of
        [Value | _] ->
            {ok, Value};

        [] ->
            {error, {path_not_found, Token}}
    end.

-file("src/gleamson/patch.gleam", 439).
-spec parse_index(binary()) -> {ok, integer()} | {error, nil}.
parse_index(Token) ->
    case gleam_stdlib:parse_int(Token) of
        {ok, I} when I >= 0 ->
            {ok, I};

        _ ->
            {error, nil}
    end.

-file("src/gleamson/patch.gleam", 216).
-spec get_child(gleamson:json(), binary()) -> {ok, gleamson:json()} |
    {error, patch_error()}.
get_child(Json, Token) ->
    case Json of
        {object, Entries} ->
            case gleam@list:key_find(Entries, Token) of
                {ok, Value} ->
                    {ok, Value};

                {error, _} ->
                    {error, {path_not_found, Token}}
            end;

        {array, Items} ->
            case parse_index(Token) of
                {ok, I} ->
                    element_at(Items, I, Token);

                {error, _} ->
                    {error, {invalid_path, Token}}
            end;

        _ ->
            {error, {path_not_found, Token}}
    end.

-file("src/gleamson/patch.gleam", 97).
-spec get_at(gleamson:json(), list(binary())) -> {ok, gleamson:json()} |
    {error, patch_error()}.
get_at(Json, Path) ->
    case Path of
        [] ->
            {ok, Json};

        [Head | Rest] ->
            gleam@result:'try'(
                get_child(Json, Head),
                fun(Child) -> get_at(Child, Rest) end
            )
    end.

-file("src/gleamson/patch.gleam", 423).
-spec unescape(binary()) -> binary().
unescape(Token) ->
    _pipe = Token,
    _pipe@1 = gleam@string:replace(_pipe, <<"~1"/utf8>>, <<"/"/utf8>>),
    gleam@string:replace(_pipe@1, <<"~0"/utf8>>, <<"~"/utf8>>).

-file("src/gleamson/patch.gleam", 412).
-spec tokens(binary()) -> {ok, list(binary())} | {error, patch_error()}.
tokens(Path) ->
    case Path of
        <<""/utf8>> ->
            {ok, []};

        _ ->
            case gleam@string:split(Path, <<"/"/utf8>>) of
                [<<""/utf8>> | Rest] ->
                    {ok, gleam@list:map(Rest, fun unescape/1)};

                _ ->
                    {error, {invalid_path, Path}}
            end
    end.

-file("src/gleamson/patch.gleam", 495).
-spec replace_index(list(gleamson:json()), integer(), gleamson:json()) -> list(gleamson:json()).
replace_index(Items, I, Value) ->
    {Before, After} = gleam@list:split(Items, I),
    case After of
        [_ | Tail] ->
            lists:append(Before, [Value | Tail]);

        [] ->
            lists:append(Before, [Value])
    end.

-file("src/gleamson/patch.gleam", 446).
-spec has_key(list({binary(), gleamson:json()}), binary()) -> boolean().
has_key(Entries, Key) ->
    gleam@list:any(Entries, fun(Entry) -> erlang:element(1, Entry) =:= Key end).

-file("src/gleamson/patch.gleam", 457).
-spec set_key(list({binary(), gleamson:json()}), binary(), gleamson:json()) -> list({binary(),
    gleamson:json()}).
set_key(Entries, Key, Value) ->
    case has_key(Entries, Key) of
        true ->
            gleam@list:map(
                Entries,
                fun(Entry) -> case erlang:element(1, Entry) =:= Key of
                        true ->
                            {Key, Value};

                        false ->
                            Entry
                    end end
            );

        false ->
            lists:append(Entries, [{Key, Value}])
    end.

-file("src/gleamson/patch.gleam", 232).
-spec set_child(gleamson:json(), binary(), gleamson:json()) -> {ok,
        gleamson:json()} |
    {error, patch_error()}.
set_child(Json, Token, Child) ->
    case Json of
        {object, Entries} ->
            {ok, {object, set_key(Entries, Token, Child)}};

        {array, Items} ->
            case parse_index(Token) of
                {ok, I} ->
                    case I < erlang:length(Items) of
                        true ->
                            {ok, {array, replace_index(Items, I, Child)}};

                        false ->
                            {error, {path_not_found, Token}}
                    end;

                {error, _} ->
                    {error, {invalid_path, Token}}
            end;

        _ ->
            {error, {path_not_found, Token}}
    end.

-file("src/gleamson/patch.gleam", 482).
-spec insert_at(list(gleamson:json()), integer(), gleamson:json()) -> list(gleamson:json()).
insert_at(Items, I, Value) ->
    {Before, After} = gleam@list:split(Items, I),
    lists:append(Before, [Value | After]).

-file("src/gleamson/patch.gleam", 124).
-spec add_here(gleamson:json(), binary(), gleamson:json()) -> {ok,
        gleamson:json()} |
    {error, patch_error()}.
add_here(Json, Token, Value) ->
    case Json of
        {object, Entries} ->
            {ok, {object, set_key(Entries, Token, Value)}};

        {array, Items} ->
            case Token of
                <<"-"/utf8>> ->
                    {ok, {array, lists:append(Items, [Value])}};

                _ ->
                    case parse_index(Token) of
                        {ok, I} ->
                            case I =< erlang:length(Items) of
                                true ->
                                    {ok, {array, insert_at(Items, I, Value)}};

                                false ->
                                    {error, {path_not_found, Token}}
                            end;

                        {error, _} ->
                            {error, {invalid_path, Token}}
                    end
            end;

        _ ->
            {error, {path_not_found, Token}}
    end.

-file("src/gleamson/patch.gleam", 107).
-spec add_at(gleamson:json(), list(binary()), gleamson:json()) -> {ok,
        gleamson:json()} |
    {error, patch_error()}.
add_at(Json, Path, Value) ->
    case Path of
        [] ->
            {ok, Value};

        [Token] ->
            add_here(Json, Token, Value);

        [Head | Rest] ->
            gleam@result:'try'(
                get_child(Json, Head),
                fun(Child) ->
                    gleam@result:'try'(
                        add_at(Child, Rest, Value),
                        fun(Updated) -> set_child(Json, Head, Updated) end
                    )
                end
            )
    end.

-file("src/gleamson/patch.gleam", 487).
-spec delete_at(list(gleamson:json()), integer()) -> list(gleamson:json()).
delete_at(Items, I) ->
    {Before, After} = gleam@list:split(Items, I),
    case After of
        [_ | Tail] ->
            lists:append(Before, Tail);

        [] ->
            Before
    end.

-file("src/gleamson/patch.gleam", 450).
-spec delete_key(list({binary(), gleamson:json()}), binary()) -> list({binary(),
    gleamson:json()}).
delete_key(Entries, Key) ->
    gleam@list:filter(
        Entries,
        fun(Entry) -> erlang:element(1, Entry) /= Key end
    ).

-file("src/gleamson/patch.gleam", 156).
-spec remove_here(gleamson:json(), binary()) -> {ok, gleamson:json()} |
    {error, patch_error()}.
remove_here(Json, Token) ->
    case Json of
        {object, Entries} ->
            case has_key(Entries, Token) of
                true ->
                    {ok, {object, delete_key(Entries, Token)}};

                false ->
                    {error, {path_not_found, Token}}
            end;

        {array, Items} ->
            case parse_index(Token) of
                {ok, I} ->
                    case I < erlang:length(Items) of
                        true ->
                            {ok, {array, delete_at(Items, I)}};

                        false ->
                            {error, {path_not_found, Token}}
                    end;

                {error, _} ->
                    {error, {invalid_path, Token}}
            end;

        _ ->
            {error, {path_not_found, Token}}
    end.

-file("src/gleamson/patch.gleam", 144).
-spec remove_at(gleamson:json(), list(binary())) -> {ok, gleamson:json()} |
    {error, patch_error()}.
remove_at(Json, Path) ->
    case Path of
        [] ->
            {error, {invalid_path, <<""/utf8>>}};

        [Token] ->
            remove_here(Json, Token);

        [Head | Rest] ->
            gleam@result:'try'(
                get_child(Json, Head),
                fun(Child) ->
                    gleam@result:'try'(
                        remove_at(Child, Rest),
                        fun(Updated) -> set_child(Json, Head, Updated) end
                    )
                end
            )
    end.

-file("src/gleamson/patch.gleam", 192).
-spec replace_here(gleamson:json(), binary(), gleamson:json()) -> {ok,
        gleamson:json()} |
    {error, patch_error()}.
replace_here(Json, Token, Value) ->
    case Json of
        {object, Entries} ->
            case has_key(Entries, Token) of
                true ->
                    {ok, {object, set_key(Entries, Token, Value)}};

                false ->
                    {error, {path_not_found, Token}}
            end;

        {array, Items} ->
            case parse_index(Token) of
                {ok, I} ->
                    case I < erlang:length(Items) of
                        true ->
                            {ok, {array, replace_index(Items, I, Value)}};

                        false ->
                            {error, {path_not_found, Token}}
                    end;

                {error, _} ->
                    {error, {invalid_path, Token}}
            end;

        _ ->
            {error, {path_not_found, Token}}
    end.

-file("src/gleamson/patch.gleam", 176).
-spec replace_at(gleamson:json(), list(binary()), gleamson:json()) -> {ok,
        gleamson:json()} |
    {error, patch_error()}.
replace_at(Json, Path, Value) ->
    case Path of
        [] ->
            {ok, Value};

        [Token] ->
            replace_here(Json, Token, Value);

        [Head | Rest] ->
            gleam@result:'try'(
                get_child(Json, Head),
                fun(Child) ->
                    gleam@result:'try'(
                        replace_at(Child, Rest, Value),
                        fun(Updated) -> set_child(Json, Head, Updated) end
                    )
                end
            )
    end.

-file("src/gleamson/patch.gleam", 57).
-spec apply_one(gleamson:json(), operation()) -> {ok, gleamson:json()} |
    {error, patch_error()}.
apply_one(Json, Operation) ->
    case Operation of
        {add, Path, Value} ->
            gleam@result:'try'(
                tokens(Path),
                fun(Path@1) -> add_at(Json, Path@1, Value) end
            );

        {remove, Path@2} ->
            gleam@result:'try'(
                tokens(Path@2),
                fun(Path@3) -> remove_at(Json, Path@3) end
            );

        {replace, Path@4, Value@1} ->
            gleam@result:'try'(
                tokens(Path@4),
                fun(Path@5) -> replace_at(Json, Path@5, Value@1) end
            );

        {copy, From, Path@6} ->
            gleam@result:'try'(
                tokens(From),
                fun(From@1) ->
                    gleam@result:'try'(
                        tokens(Path@6),
                        fun(Path@7) ->
                            gleam@result:'try'(
                                get_at(Json, From@1),
                                fun(Value@2) ->
                                    add_at(Json, Path@7, Value@2)
                                end
                            )
                        end
                    )
                end
            );

        {move, From@2, Path@8} ->
            gleam@result:'try'(
                tokens(From@2),
                fun(From@3) ->
                    gleam@result:'try'(
                        tokens(Path@8),
                        fun(Path@9) ->
                            gleam@result:'try'(
                                get_at(Json, From@3),
                                fun(Value@3) ->
                                    gleam@result:'try'(
                                        remove_at(Json, From@3),
                                        fun(Without) ->
                                            add_at(Without, Path@9, Value@3)
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            );

        {test, Path@10, Value@4} ->
            gleam@result:'try'(
                tokens(Path@10),
                fun(Parsed) ->
                    gleam@result:'try'(
                        get_at(Json, Parsed),
                        fun(Actual) ->
                            case gleamson:semantically_equal(Actual, Value@4) of
                                true ->
                                    {ok, Json};

                                false ->
                                    {error,
                                        {test_failed, Path@10, Value@4, Actual}}
                            end
                        end
                    )
                end
            )
    end.

-file("src/gleamson/patch.gleam", 50).
?DOC(" Apply a patch to a document. All operations succeed, or none are applied.\n").
-spec apply(gleamson:json(), list(operation())) -> {ok, gleamson:json()} |
    {error, patch_error()}.
apply(Json, Operations) ->
    gleam@list:try_fold(Operations, Json, fun apply_one/2).

-file("src/gleamson/patch.gleam", 433).
-spec escape(binary()) -> binary().
escape(Token) ->
    _pipe = Token,
    _pipe@1 = gleam@string:replace(_pipe, <<"~"/utf8>>, <<"~0"/utf8>>),
    gleam@string:replace(_pipe@1, <<"/"/utf8>>, <<"~1"/utf8>>).

-file("src/gleamson/patch.gleam", 429).
-spec join(binary(), binary()) -> binary().
join(Path, Token) ->
    <<<<Path/binary, "/"/utf8>>/binary, (escape(Token))/binary>>.

-file("src/gleamson/patch.gleam", 294).
-spec diff_arrays(
    list(gleamson:json()),
    list(gleamson:json()),
    binary(),
    integer()
) -> list(operation()).
diff_arrays(A, B, Path, I) ->
    case {A, B} of
        {[], []} ->
            [];

        {[], [Y | Ys]} ->
            [{add, join(Path, <<"-"/utf8>>), Y} | diff_arrays([], Ys, Path, I)];

        {[_ | Xs], []} ->
            [{remove, join(Path, erlang:integer_to_binary(I))} |
                diff_arrays(Xs, [], Path, I)];

        {[X | Xs@1], [Y@1 | Ys@1]} ->
            lists:append(
                diff_at(X, Y@1, join(Path, erlang:integer_to_binary(I))),
                diff_arrays(Xs@1, Ys@1, Path, I + 1)
            )
    end.

-file("src/gleamson/patch.gleam", 271).
-spec diff_objects(
    list({binary(), gleamson:json()}),
    list({binary(), gleamson:json()}),
    binary()
) -> list(operation()).
diff_objects(A, B, Path) ->
    Removes = gleam@list:filter_map(
        A,
        fun(Entry) -> case has_key(B, erlang:element(1, Entry)) of
                true ->
                    {error, nil};

                false ->
                    {ok, {remove, join(Path, erlang:element(1, Entry))}}
            end end
    ),
    Changes = gleam@list:flat_map(
        B,
        fun(Entry@1) ->
            {Key, Value_b} = Entry@1,
            case gleam@list:key_find(A, Key) of
                {ok, Value_a} ->
                    diff_at(Value_a, Value_b, join(Path, Key));

                {error, _} ->
                    [{add, join(Path, Key), Value_b}]
            end
        end
    ),
    lists:append(Removes, Changes).

-file("src/gleamson/patch.gleam", 259).
-spec diff_at(gleamson:json(), gleamson:json(), binary()) -> list(operation()).
diff_at(From, To, Path) ->
    case gleamson:semantically_equal(From, To) of
        true ->
            [];

        false ->
            case {From, To} of
                {{object, A}, {object, B}} ->
                    diff_objects(A, B, Path);

                {{array, A@1}, {array, B@1}} ->
                    diff_arrays(A@1, B@1, Path, 0);

                {_, _} ->
                    [{replace, Path, To}]
            end
    end.

-file("src/gleamson/patch.gleam", 255).
?DOC(
    " Compute a patch that turns `from` into `to`. The result is correct but not\n"
    " guaranteed minimal: array changes are emitted positionally rather than by\n"
    " detecting moves.\n"
).
-spec diff(gleamson:json(), gleamson:json()) -> list(operation()).
diff(From, To) ->
    diff_at(From, To, <<""/utf8>>).

-file("src/gleamson/patch.gleam", 326).
-spec operation_to_json(operation()) -> gleamson:json().
operation_to_json(Operation) ->
    case Operation of
        {add, Path, Value} ->
            {object,
                [{<<"op"/utf8>>, {string, <<"add"/utf8>>}},
                    {<<"path"/utf8>>, {string, Path}},
                    {<<"value"/utf8>>, Value}]};

        {remove, Path@1} ->
            {object,
                [{<<"op"/utf8>>, {string, <<"remove"/utf8>>}},
                    {<<"path"/utf8>>, {string, Path@1}}]};

        {replace, Path@2, Value@1} ->
            {object,
                [{<<"op"/utf8>>, {string, <<"replace"/utf8>>}},
                    {<<"path"/utf8>>, {string, Path@2}},
                    {<<"value"/utf8>>, Value@1}]};

        {move, From, Path@3} ->
            {object,
                [{<<"op"/utf8>>, {string, <<"move"/utf8>>}},
                    {<<"from"/utf8>>, {string, From}},
                    {<<"path"/utf8>>, {string, Path@3}}]};

        {copy, From@1, Path@4} ->
            {object,
                [{<<"op"/utf8>>, {string, <<"copy"/utf8>>}},
                    {<<"from"/utf8>>, {string, From@1}},
                    {<<"path"/utf8>>, {string, Path@4}}]};

        {test, Path@5, Value@2} ->
            {object,
                [{<<"op"/utf8>>, {string, <<"test"/utf8>>}},
                    {<<"path"/utf8>>, {string, Path@5}},
                    {<<"value"/utf8>>, Value@2}]}
    end.

-file("src/gleamson/patch.gleam", 322).
?DOC(" Encode a patch as a JSON value (an array of operation objects).\n").
-spec to_json(list(operation())) -> gleamson:json().
to_json(Operations) ->
    gleamson:array(Operations, fun operation_to_json/1).

-file("src/gleamson/patch.gleam", 368).
-spec operation_decoder() -> fun((gleamson:json()) -> {operation(),
    list(gleamson@decode:decode_error())}).
operation_decoder() ->
    gleamson@decode:field(
        <<"op"/utf8>>,
        fun gleamson@decode:string/1,
        fun(Op) -> case Op of
                <<"add"/utf8>> ->
                    gleamson@decode:field(
                        <<"path"/utf8>>,
                        fun gleamson@decode:string/1,
                        fun(Path) ->
                            gleamson@decode:field(
                                <<"value"/utf8>>,
                                fun gleamson@decode:json/1,
                                fun(Value) ->
                                    gleamson@decode:success({add, Path, Value})
                                end
                            )
                        end
                    );

                <<"remove"/utf8>> ->
                    gleamson@decode:field(
                        <<"path"/utf8>>,
                        fun gleamson@decode:string/1,
                        fun(Path@1) ->
                            gleamson@decode:success({remove, Path@1})
                        end
                    );

                <<"replace"/utf8>> ->
                    gleamson@decode:field(
                        <<"path"/utf8>>,
                        fun gleamson@decode:string/1,
                        fun(Path@2) ->
                            gleamson@decode:field(
                                <<"value"/utf8>>,
                                fun gleamson@decode:json/1,
                                fun(Value@1) ->
                                    gleamson@decode:success(
                                        {replace, Path@2, Value@1}
                                    )
                                end
                            )
                        end
                    );

                <<"move"/utf8>> ->
                    gleamson@decode:field(
                        <<"from"/utf8>>,
                        fun gleamson@decode:string/1,
                        fun(From) ->
                            gleamson@decode:field(
                                <<"path"/utf8>>,
                                fun gleamson@decode:string/1,
                                fun(Path@3) ->
                                    gleamson@decode:success(
                                        {move, From, Path@3}
                                    )
                                end
                            )
                        end
                    );

                <<"copy"/utf8>> ->
                    gleamson@decode:field(
                        <<"from"/utf8>>,
                        fun gleamson@decode:string/1,
                        fun(From@1) ->
                            gleamson@decode:field(
                                <<"path"/utf8>>,
                                fun gleamson@decode:string/1,
                                fun(Path@4) ->
                                    gleamson@decode:success(
                                        {copy, From@1, Path@4}
                                    )
                                end
                            )
                        end
                    );

                <<"test"/utf8>> ->
                    gleamson@decode:field(
                        <<"path"/utf8>>,
                        fun gleamson@decode:string/1,
                        fun(Path@5) ->
                            gleamson@decode:field(
                                <<"value"/utf8>>,
                                fun gleamson@decode:json/1,
                                fun(Value@2) ->
                                    gleamson@decode:success(
                                        {test, Path@5, Value@2}
                                    )
                                end
                            )
                        end
                    );

                _ ->
                    gleamson@decode:failure(
                        {remove, <<""/utf8>>},
                        <<"a JSON Patch op (add/remove/replace/move/copy/test)"/utf8>>
                    )
            end end
    ).

-file("src/gleamson/patch.gleam", 364).
?DOC(
    " A decoder for a JSON Patch document (an array of operations). Pair it with\n"
    " `gleamson.parse` / `decode.from_string` to read a patch off the wire.\n"
).
-spec decoder() -> fun((gleamson:json()) -> {list(operation()),
    list(gleamson@decode:decode_error())}).
decoder() ->
    gleamson@decode:list(operation_decoder()).