From 2eca3a53380fbdcba67478d7a44695e7a3ca43af Mon Sep 17 00:00:00 2001 From: Jarvis Carroll Date: Tue, 12 May 2026 04:23:21 +0000 Subject: [PATCH 1/3] Handle singleton records in erlang_to_fate I realized this case needed special handling in hz_sophia, but didn't get around to covering it properly in the older hz_aaci analogues. While I was at it, I went and improved the error paths for record elements. --- src/hz_aaci.erl | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/hz_aaci.erl b/src/hz_aaci.erl index c4fd79f..4aa095d 100644 --- a/src/hz_aaci.erl +++ b/src/hz_aaci.erl @@ -796,6 +796,10 @@ coerce_map_to_record(O, N, MemberTypes, Map) -> case zip_record_fields(MemberTypes, Map) of {ok, Zipped} -> case coerce_zipped_bindings(Zipped, to_fate, field) of + {ok, [SingleElem]} -> + % Singleton records aren't implemented as FATE tuples at + % all. + {ok, SingleElem}; {ok, Converted} -> {ok, {tuple, list_to_tuple(Converted)}}; Errors -> @@ -821,10 +825,18 @@ coerce_record_to_map(O, N, MemberTypes, Tuple) -> single_error({record_too_few_terms, O, N, Tuple}); {error, too_many_terms} -> single_error({record_too_many_terms, O, N, Tuple}); - Errors -> - Errors + {error, Errors} -> + correct_record_error_paths(Names, Errors) end. +correct_record_error_paths(Names, Errors) -> + CorrectOne = fun({Error, [{record_element, N} | Path]}) -> + FieldName = lists:nth(N + 1, Names), + {Error, [{record_element, FieldName} | Path]} + end, + Corrected = lists:map(CorrectOne, Errors), + {error, Corrected}. + zip_record_fields(Fields, Map) -> case lists:mapfoldl(fun zip_record_field/2, {Map, []}, Fields) of {_, {_, Missing = [_|_]}} -> @@ -915,6 +927,11 @@ fate_to_erlang({O, N, {variant, Variants}}, {variant, _, Tag, Tuple}) -> Terms = tuple_to_list(Tuple), {Name, TermTypes} = lists:nth(Tag + 1, Variants), coerce_variant2(O, N, Variants, Name, Tag, TermTypes, Terms, from_fate); +fate_to_erlang({O, N, {record, [SingleMemberType]}}, Data) -> + % Singleton records aren't implemented as FATE tuples at all. + % Pretend they are, so we can get the full error indexing of the + % non-singletone case. + coerce_record_to_map(O, N, [SingleMemberType], {Data}); fate_to_erlang({O, N, {record, MemberTypes}}, {tuple, Tuple}) -> coerce_record_to_map(O, N, MemberTypes, Tuple); fate_to_erlang({O, N, {unknown_type, _}}, Data) -> @@ -1120,6 +1137,25 @@ record_substitution_test() -> {ok, {[], Output}} = get_function_signature(AACI, "f"), check_roundtrip(Output, #{"a" => 123, "b" => 456}, {tuple, {123, 456}}). +singleton_record_substitution_test() -> + Contract = " + contract C = + record single('t) = { it: 't } + entrypoint f(): single(int) = { it = 1 } + entrypoint g(): single(single(int)) = { it = { it = 2 } } + entrypoint h(): single(int * int) = { it = (3, 4) } + ", + {ok, AACI} = aaci_from_string(Contract), + {ok, {[], FOutput}} = get_function_signature(AACI, "f"), + check_roundtrip(FOutput, #{"it" => 123}, 123), + {ok, {[], GOutput}} = get_function_signature(AACI, "g"), + check_roundtrip(GOutput, #{"it" => #{"it" => 123}}, 123), + {ok, {[], HOutput}} = get_function_signature(AACI, "h"), + check_roundtrip(HOutput, #{"it" => {123, 456}}, {tuple, {123, 456}}), + % Also check that records have accurate paths, since the implementation for + % record error paths is a bit fiddly. + {error, [{{tuple_too_many_terms, _, _, _}, [{record_element, "it"}]}]} = fate_to_erlang(HOutput, {tuple, {1, 2, 3}}). + tuple_substitution_test() -> Contract = " contract C = From 5dcc05d56a00851a2d4f956fa34267576b0e2d75 Mon Sep 17 00:00:00 2001 From: Jarvis Carroll Date: Tue, 12 May 2026 06:00:26 +0000 Subject: [PATCH 2/3] Change fate_to_erlang warning This warning always confuses me. Usually it is a case I haven't actually implemented, but I don't need the program to diagnose that for me, I need the program to tell me what the type was, so that I can work out why it thinks it isn't implemented. All three terms of the annotated type are relevant, but the annotated version can only differ from the normalized version if it is a record or variant definition, so we special case those two just to communicate that the fact that it is *some* kind of record did successfully pass through to the coerce logic, and otherwise we just try and print the opaque and normalized types faithfully. --- src/hz_aaci.erl | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/hz_aaci.erl b/src/hz_aaci.erl index 4aa095d..1313b9b 100644 --- a/src/hz_aaci.erl +++ b/src/hz_aaci.erl @@ -944,15 +944,30 @@ fate_to_erlang({O, N, {unknown_type, _}}, Data) -> io:format(Message, [O, N, Data]) end, {ok, Data}; -fate_to_erlang({O, N, _}, Data) -> - case N of - already_normalized -> - io:format("Warning: Unimplemented type ~p.~nUsing term as is:~n~p~n", [O, Data]); - _ -> - io:format("Warning: Unimplemented type ~p (i.e. ~p).~nUsing term as is:~n~p~n", [O, N, Data]) - end, +fate_to_erlang(Type, Data) -> + TypeStr = type_to_iolist(Type), + io:format("Warning: Could not coerce term into ~s. Using term as is: ~p~n", [TypeStr, Data]), {ok, Data}. +type_to_iolist({O, already_normalized, S}) -> + % Already normalized. Example output: + % type {map, [string, integer]} + opaque_type_to_iolist(O, S); +type_to_iolist({O, N, S}) -> + % Type alias. Print the alias, and then print the normalized version in + % parentheses. Example output: + % type "my_alias" (i.e. record type {"my_record_type", [integer]}) + io_lib:format("type ~p (i.e. ~s)", [O, opaque_type_to_iolist(N, S)]). + +opaque_type_to_iolist(N, {record, _}) -> + % N is the name of a record definition. + io_lib:format("record type ~p", [N]); +opaque_type_to_iolist(N, {variant, _}) -> + % N is the name of a variant definition. + io_lib:format("variant type ~p", [N]); +opaque_type_to_iolist(N, _) -> + % N is some other constructive type. + io_lib:format("type ~p", [N]). %%% AACI Getters From ed252b4c06e1431aa98f07203127051b58bc17d5 Mon Sep 17 00:00:00 2001 From: Jarvis Carroll Date: Tue, 12 May 2026 06:07:58 +0000 Subject: [PATCH 3/3] Also note index in record_element I changed it from noting the index to just noting the field name, but actually both pieces of information are important, since if there was a type error, presumably the type information is actually wrong. Now we put the index first, since that is the part of the FATE tuple that failed, and then the field name that that would be if the type information were correct, in case that is useful. --- src/hz_aaci.erl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hz_aaci.erl b/src/hz_aaci.erl index 1313b9b..ebf1c6a 100644 --- a/src/hz_aaci.erl +++ b/src/hz_aaci.erl @@ -832,7 +832,7 @@ coerce_record_to_map(O, N, MemberTypes, Tuple) -> correct_record_error_paths(Names, Errors) -> CorrectOne = fun({Error, [{record_element, N} | Path]}) -> FieldName = lists:nth(N + 1, Names), - {Error, [{record_element, FieldName} | Path]} + {Error, [{record_element, N, FieldName} | Path]} end, Corrected = lists:map(CorrectOne, Errors), {error, Corrected}. @@ -1169,7 +1169,7 @@ singleton_record_substitution_test() -> check_roundtrip(HOutput, #{"it" => {123, 456}}, {tuple, {123, 456}}), % Also check that records have accurate paths, since the implementation for % record error paths is a bit fiddly. - {error, [{{tuple_too_many_terms, _, _, _}, [{record_element, "it"}]}]} = fate_to_erlang(HOutput, {tuple, {1, 2, 3}}). + {error, [{{tuple_too_many_terms, _, _, _}, [{record_element, 0, "it"}]}]} = fate_to_erlang(HOutput, {tuple, {1, 2, 3}}). tuple_substitution_test() -> Contract = "