Skip to content

Commit 15cc023

Browse files
mjambonclaude
andauthored
atdd: add <json repr="object"> for sum types (externally-tagged encoding) (#490)
* atdd: add <json repr="object"> for sum types Tagged variants are encoded as single-key JSON objects {"Constructor": payload} instead of the default two-element array ["Constructor", payload]. This matches the default Rust/Serde externally-tagged encoding. It also reads naturally in YAML as a single-key mapping, which is one motivation for the feature. Unit variants (no payload) are always encoded as plain strings regardless of the repr annotation. The D associative-array literal ["key": value] is used for encoding. The decoder checks JSONType.object_, accesses the sole key via x.object_.keys.front, and uses x["key"] to get the payload value. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * atdd: fix D object repr for newer std.json API (ldc 1.41 / DMD 2.111) Newer versions of std.json dropped the trailing underscore on the JSONType.object_ enum case and JSONValue.object_ property (the underscore was originally needed to avoid shadowing the built-in `object` module, but the conflict was resolved in the language). Also use .keys[0] instead of .keys.front to extract the first key of a single-element object without needing std.range in scope. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 948706a commit 15cc023

5 files changed

Lines changed: 167 additions & 22 deletions

File tree

atdd/src/lib/Codegen.ml

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -914,11 +914,12 @@ let alias_wrapper env name type_expr =
914914
Line("}");
915915
]
916916

917-
let case_class env type_name
918-
(loc, orig_name, unique_name, an, opt_e) =
917+
let case_class env type_name
918+
(loc, orig_name, unique_name, an, opt_e) ~json_sum_repr =
919919
let json_name = Atd.Json.get_json_cons orig_name an in
920920
match opt_e with
921921
| None ->
922+
(* Unit variants are always encoded as plain strings. *)
922923
[
923924
Line (sprintf {|// Original type: %s = [ ... | %s | ... ]|}
924925
type_name
@@ -929,16 +930,29 @@ let case_class env type_name
929930
Line("}");
930931
]
931932
| Some e ->
933+
(* Tagged variants (with payload).
934+
Array repr (default): ["Constructor", payload]
935+
Object repr: {"Constructor": payload}
936+
This is the Rust/Serde default externally-tagged encoding and
937+
also maps naturally to YAML as a single-key mapping. *)
938+
let to_json_body = match json_sum_repr with
939+
| Atd.Json.Array ->
940+
sprintf "return JSONValue([JSONValue(\"%s\"), %s(e.value)]);"
941+
(single_esc json_name) (json_writer env e)
942+
| Atd.Json.Object ->
943+
sprintf "return JSONValue([\"%s\": %s(e.value)]);"
944+
(single_esc json_name) (json_writer env e)
945+
in
932946
[
933947
Line (sprintf {|// Original type: %s = [ ... | %s of ... | ... ]|}
934948
type_name
935949
orig_name);
936-
Line (sprintf "struct %s { %s value; }" (trans env unique_name) (type_name_of_expr env e)); (* TODO : very dubious*)
950+
Line (sprintf "struct %s { %s value; }" (trans env unique_name) (type_name_of_expr env e));
937951
Line (sprintf "@trusted JSONValue toJson(T : %s)(T e) {" (trans env unique_name));
938-
Block [Line(sprintf "return JSONValue([JSONValue(\"%s\"), %s(e.value)]);" (single_esc json_name) (json_writer env e))];
952+
Block [Line to_json_body];
939953
Line("}");
940954
]
941-
955+
942956

943957
let read_cases0 env loc name cases0 =
944958
let ifs =
@@ -959,7 +973,10 @@ let read_cases0 env loc name cases0 =
959973
(struct_name env name |> single_esc))
960974
]
961975

962-
let read_cases1 env loc name cases1 =
976+
let read_cases1 env loc name cases1 json_sum_repr =
977+
(* How the payload value is accessed depends on the sum repr:
978+
Array: x[1] -- value is the second element of ["Constructor", payload]
979+
Object: x["Constructor"] -- value is the field under the constructor key *)
963980
let ifs =
964981
cases1
965982
|> List.map (fun (loc, orig_name, unique_name, an, opt_e) ->
@@ -969,13 +986,20 @@ let read_cases1 env loc name cases1 =
969986
| Some x -> x
970987
in
971988
let json_name = Atd.Json.get_json_cons orig_name an in
989+
let value_expr = match json_sum_repr with
990+
| Atd.Json.Array -> "x[1]"
991+
| Atd.Json.Object ->
992+
(* Use the literal key rather than 'cons' for clarity. *)
993+
sprintf "x[\"%s\"]" (single_esc json_name)
994+
in
972995
Inline [
973996
Line (sprintf "if (cons == \"%s\")" (single_esc json_name));
974997
Block [
975-
Line (sprintf "return %s(%s(%s(x[1])));"
998+
Line (sprintf "return %s(%s(%s(%s)));"
976999
(struct_name env name)
9771000
(trans env unique_name)
978-
(json_reader env e))
1001+
(json_reader env e)
1002+
value_expr)
9791003
]
9801004
]
9811005
)
@@ -986,7 +1010,7 @@ let read_cases1 env loc name cases1 =
9861010
(struct_name env name |> single_esc))
9871011
]
9881012

989-
let sum_container env loc name cases =
1013+
let sum_container env loc name cases an =
9901014
let dlang_struct_name = struct_name env name in
9911015
let type_list =
9921016
List.map (fun (loc, orig_name, unique_name, an, opt_e) ->
@@ -1002,23 +1026,42 @@ let sum_container env loc name cases =
10021026
let cases0_block =
10031027
if cases0 <> [] then
10041028
[
1029+
(* Unit variants are always encoded as plain strings. *)
10051030
Line "if (x.type == JSONType.string) {";
10061031
Block (read_cases0 env loc name cases0);
10071032
Line "}";
10081033
]
10091034
else
10101035
[]
10111036
in
1037+
(* Determine how tagged variants are decoded based on the sum repr.
1038+
Array (default): ["Constructor", payload]
1039+
The tag is x[0].str and the payload is x[1].
1040+
Object: {"Constructor": payload}
1041+
This is the Rust/Serde default externally-tagged encoding and also
1042+
maps naturally to YAML. The tag is the sole key of the object. *)
1043+
let json_sum_repr = (Atd.Json.get_json_sum an).json_sum_repr in
10121044
let cases1_block =
10131045
if cases1 <> [] then
1014-
[
1015-
Line "if (x.type == JSONType.array && x.array.length == 2 && x[0].type == JSONType.string) {";
1016-
Block [
1017-
Line "string cons = x[0].str;";
1018-
Inline (read_cases1 env loc name cases1)
1019-
];
1020-
Line "}";
1021-
]
1046+
match json_sum_repr with
1047+
| Atd.Json.Array ->
1048+
[
1049+
Line "if (x.type == JSONType.array && x.array.length == 2 && x[0].type == JSONType.string) {";
1050+
Block [
1051+
Line "string cons = x[0].str;";
1052+
Inline (read_cases1 env loc name cases1 Atd.Json.Array)
1053+
];
1054+
Line "}";
1055+
]
1056+
| Atd.Json.Object ->
1057+
[
1058+
Line "if (x.type == JSONType.object && x.object.length == 1) {";
1059+
Block [
1060+
Line "string cons = x.object.keys[0];";
1061+
Inline (read_cases1 env loc name cases1 Atd.Json.Object)
1062+
];
1063+
Line "}";
1064+
]
10221065
else
10231066
[]
10241067
in
@@ -1050,7 +1093,8 @@ let sum_container env loc name cases =
10501093
]
10511094

10521095

1053-
let sum env loc name cases =
1096+
let sum env loc name cases an =
1097+
let json_sum_repr = (Atd.Json.get_json_sum an).json_sum_repr in
10541098
let cases =
10551099
List.map (fun (x : variant) ->
10561100
match x with
@@ -1061,10 +1105,10 @@ let sum env loc name cases =
10611105
) cases
10621106
in
10631107
let case_classes =
1064-
List.map (fun x -> Inline (case_class env name x)) cases
1108+
List.map (fun x -> Inline (case_class env name x ~json_sum_repr)) cases
10651109
|> double_spaced
10661110
in
1067-
let container_class = sum_container env loc name cases in
1111+
let container_class = sum_container env loc name cases an in
10681112
[
10691113
Inline case_classes;
10701114
Inline container_class;
@@ -1081,7 +1125,7 @@ let type_def env (def : A.type_def) : B.t =
10811125
let unwrap e =
10821126
match e with
10831127
| Sum (loc, cases, an) ->
1084-
sum env loc name cases
1128+
sum env loc name cases an
10851129
| Record (loc, fields, an) ->
10861130
record env loc name fields an
10871131
| Tuple _

atdd/test/atd-input/everything.atd

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,14 @@ type default_list = {
8484
type record_with_wrapped_type = {
8585
item: string wrap <dlang t="int" wrap="to!int" unwrap="to!string">;
8686
}
87+
88+
(* Test for <json repr="object"> on sum types.
89+
Tagged variants are encoded as single-key JSON objects {"Constructor": payload}
90+
instead of the default two-element array ["Constructor", payload].
91+
This matches the default Rust/Serde externally-tagged encoding and
92+
also maps naturally to YAML (each variant is a single-key mapping). *)
93+
type shape = [
94+
| Circle of float (* radius *)
95+
| Square of float (* side length *)
96+
| Point (* unit variant -- still encoded as a plain string *)
97+
] <json repr="object">

atdd/test/dlang-expected/everything_atd.d

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,56 @@ this(St init) @safe {_data = init._data;}
365365
}
366366

367367

368+
// Original type: shape = [ ... | Circle of ... | ... ]
369+
struct Circle { float value; }
370+
@trusted JSONValue toJson(T : Circle)(T e) {
371+
return JSONValue(["Circle": _atd_write_float(e.value)]);
372+
}
373+
374+
375+
// Original type: shape = [ ... | Square of ... | ... ]
376+
struct Square { float value; }
377+
@trusted JSONValue toJson(T : Square)(T e) {
378+
return JSONValue(["Square": _atd_write_float(e.value)]);
379+
}
380+
381+
382+
// Original type: shape = [ ... | Point | ... ]
383+
struct Point {}
384+
@trusted JSONValue toJson(T : Point)(T e) {
385+
return JSONValue("Point");
386+
}
387+
388+
389+
struct Shape{ SumType!(Circle, Square, Point) _data; alias _data this;
390+
@safe this(T)(T init) {_data = init;} @safe this(Shape init) {_data = init._data;}}
391+
392+
@trusted Shape fromJson(T : Shape)(JSONValue x) {
393+
if (x.type == JSONType.string) {
394+
if (x.str == "Point")
395+
return Shape(Point());
396+
throw _atd_bad_json("Shape", x);
397+
}
398+
if (x.type == JSONType.object && x.object.length == 1) {
399+
string cons = x.object.keys[0];
400+
if (cons == "Circle")
401+
return Shape(Circle(_atd_read_float(x["Circle"])));
402+
if (cons == "Square")
403+
return Shape(Square(_atd_read_float(x["Square"])));
404+
throw _atd_bad_json("Shape", x);
405+
}
406+
throw _atd_bad_json("Shape", x);
407+
}
408+
409+
@trusted JSONValue toJson(T : Shape)(T x) {
410+
return x.match!(
411+
(Circle v) => v.toJson!(Circle),
412+
(Square v) => v.toJson!(Square),
413+
(Point v) => v.toJson!(Point)
414+
);
415+
}
416+
417+
368418
// Original type: kind = [ ... | Root | ... ]
369419
struct Root_ {}
370420
@trusted JSONValue toJson(T : Root_)(T e) {

atdd/test/dlang-tests/test_atdd.d

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,47 @@ void setupTests()
187187

188188
auto mapResult = credientials.map!((c) => c);
189189
};
190+
191+
// Test for <json repr="object"> on sum types.
192+
// Tagged variants are encoded as single-key JSON objects {"Constructor": payload}
193+
// instead of the default two-element array ["Constructor", payload].
194+
// This matches the default Rust/Serde externally-tagged encoding and
195+
// also maps naturally to YAML (each variant is a single-key mapping).
196+
tests["sum repr object"] = () {
197+
import std.json;
198+
199+
// Encoding: tagged variants use object encoding
200+
auto circle = Shape(Circle(3.14f));
201+
auto square = Shape(Square(2.0f));
202+
auto point = Shape(Point());
203+
204+
auto circleJson = circle.toJsonString!Shape;
205+
auto squareJson = square.toJsonString!Shape;
206+
auto pointJson = point.toJsonString!Shape;
207+
208+
assert(circleJson == `{"Circle":3.14}` || circleJson == `{"Circle":3.1400001049041748}`,
209+
"Circle encoding failed: " ~ circleJson);
210+
assert(squareJson == `{"Square":2.0}` || squareJson == `{"Square":2}`,
211+
"Square encoding failed: " ~ squareJson);
212+
// Unit variants remain plain strings regardless of repr
213+
assert(pointJson == `"Point"`, "Point encoding failed: " ~ pointJson);
214+
215+
// Decoding: round-trip
216+
auto c2 = `{"Circle":1.0}`.fromJsonString!Shape;
217+
auto s2 = `{"Square":2.5}`.fromJsonString!Shape;
218+
auto p2 = `"Point"`.fromJsonString!Shape;
219+
220+
assert(c2.toJsonString!Shape == `{"Circle":1.0}` ||
221+
c2.toJsonString!Shape == `{"Circle":1}`,
222+
"Circle round-trip failed");
223+
assert(s2.toJsonString!Shape == `{"Square":2.5}`,
224+
"Square round-trip failed");
225+
assert(p2.toJsonString!Shape == `"Point"`,
226+
"Point round-trip failed");
227+
228+
// Error on unknown constructor
229+
assertThrows({ `{"Triangle":3}`.fromJsonString!Shape; });
230+
};
190231
}
191232

192233
void assertThrows(T)(T fn, bool writeMsg = false)

internal/support_matrix.ml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ let languages : (string * lang_support) list = [
138138
"atdd (D)", { all_yes with
139139
doc_comments = Planned;
140140
json_repr_object = Planned;
141-
sum_repr_object = Planned;
142141
json_adapter = Planned;
143142
imports = Planned;
144143
open_enums = Planned;

0 commit comments

Comments
 (0)