Commit 5a20604820

daurnimator <quae@daurnimator.com>
2019-12-30 12:35:11
std: add json.stringify to encode arbitrary values to JSON
1 parent c5ca0fe
Changed files (1)
lib
lib/std/json.zig
@@ -1686,3 +1686,269 @@ test "string copy option" {
     }
     testing.expect(found_nocopy);
 }
+
+pub const StringifyOptions = struct {
+    // TODO: indentation options?
+    // TODO: make escaping '/' in strings optional?
+    // TODO: allow picking if []u8 is string or array?
+};
+
+pub fn stringify(
+    value: var,
+    options: StringifyOptions,
+    context: var,
+    comptime Errors: type,
+    comptime output: fn (@TypeOf(context), []const u8) Errors!void,
+) Errors!void {
+    const T = @TypeOf(value);
+    switch (@typeInfo(T)) {
+        .Float, .ComptimeFloat => {
+            return std.fmt.formatFloatScientific(value, std.fmt.FormatOptions{}, context, Errors, output);
+        },
+        .Int, .ComptimeInt => {
+            return std.fmt.formatIntValue(value, "", std.fmt.FormatOptions{}, context, Errors, output);
+        },
+        .Bool => {
+            return output(context, if (value) "true" else "false");
+        },
+        .Optional => {
+            if (value) |payload| {
+                return try stringify(payload, options, context, Errors, output);
+            } else {
+                return output(context, "null");
+            }
+        },
+        .Enum => {
+            if (comptime std.meta.trait.hasFn("jsonStringify")(T)) {
+                return value.jsonStringify(options, context, Errors, output);
+            }
+
+            @compileError("Unable to stringify enum '" ++ @typeName(T) ++ "'");
+        },
+        .Union => {
+            if (comptime std.meta.trait.hasFn("jsonStringify")(T)) {
+                return value.jsonStringify(options, context, Errors, output);
+            }
+
+            const info = @typeInfo(T).Union;
+            if (info.tag_type) |UnionTagType| {
+                inline for (info.fields) |u_field| {
+                    if (@enumToInt(@as(UnionTagType, value)) == u_field.enum_field.?.value) {
+                        return try stringify(@field(value, u_field.name), options, context, Errors, output);
+                    }
+                }
+            } else {
+                @compileError("Unable to stringify untagged union '" ++ @typeName(T) ++ "'");
+            }
+        },
+        .Struct => |S| {
+            if (comptime std.meta.trait.hasFn("jsonStringify")(T)) {
+                return value.jsonStringify(options, context, Errors, output);
+            }
+
+            try output(context, "{");
+            comptime var field_output = false;
+            inline for (S.fields) |Field, field_i| {
+                // don't include void fields
+                if (Field.field_type == void) continue;
+
+                if (!field_output) {
+                    field_output = true;
+                } else {
+                    try output(context, ",");
+                }
+
+                try stringify(Field.name, options, context, Errors, output);
+                try output(context, ":");
+                try stringify(@field(value, Field.name), options, context, Errors, output);
+            }
+            try output(context, "}");
+            return;
+        },
+        .Pointer => |ptr_info| switch (ptr_info.size) {
+            .One => {
+                // TODO: avoid loops?
+                return try stringify(value.*, options, context, Errors, output);
+            },
+            // TODO: .Many when there is a sentinel (waiting for https://github.com/ziglang/zig/pull/3972)
+            .Slice => {
+                if (ptr_info.child == u8 and std.unicode.utf8ValidateSlice(value)) {
+                    try output(context, "\"");
+                    var i: usize = 0;
+                    while (i < value.len) : (i += 1) {
+                        switch (value[i]) {
+                            // normal ascii characters
+                            0x20...0x21, 0x23...0x2E, 0x30...0x5B, 0x5D...0x7F => try output(context, value[i .. i + 1]),
+                            // control characters with short escapes
+                            '\\' => try output(context, "\\\\"),
+                            '\"' => try output(context, "\\\""),
+                            '/' => try output(context, "\\/"),
+                            0x8 => try output(context, "\\b"),
+                            0xC => try output(context, "\\f"),
+                            '\n' => try output(context, "\\n"),
+                            '\r' => try output(context, "\\r"),
+                            '\t' => try output(context, "\\t"),
+                            else => {
+                                const ulen = std.unicode.utf8ByteSequenceLength(value[i]) catch unreachable;
+                                const codepoint = std.unicode.utf8Decode(value[i .. i + ulen]) catch unreachable;
+                                if (codepoint <= 0xFFFF) {
+                                    // If the character is in the Basic Multilingual Plane (U+0000 through U+FFFF),
+                                    // then it may be represented as a six-character sequence: a reverse solidus, followed
+                                    // by the lowercase letter u, followed by four hexadecimal digits that encode the character's code point.
+                                    try output(context, "\\u");
+                                    try std.fmt.formatIntValue(codepoint, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, context, Errors, output);
+                                } else {
+                                    // To escape an extended character that is not in the Basic Multilingual Plane,
+                                    // the character is represented as a 12-character sequence, encoding the UTF-16 surrogate pair.
+                                    const high = @intCast(u16, (codepoint - 0x10000) >> 10) + 0xD800;
+                                    const low = @intCast(u16, codepoint & 0x3FF) + 0xDC00;
+                                    try output(context, "\\u");
+                                    try std.fmt.formatIntValue(high, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, context, Errors, output);
+                                    try output(context, "\\u");
+                                    try std.fmt.formatIntValue(low, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, context, Errors, output);
+                                }
+                                i += ulen - 1;
+                            },
+                        }
+                    }
+                    try output(context, "\"");
+                    return;
+                }
+
+                try output(context, "[");
+                for (value) |x, i| {
+                    if (i != 0) {
+                        try output(context, ",");
+                    }
+                    try stringify(x, options, context, Errors, output);
+                }
+                try output(context, "]");
+                return;
+            },
+            else => @compileError("Unable to stringify type '" ++ @typeName(T) ++ "'"),
+        },
+        .Array => |info| {
+            return try stringify(value[0..], options, context, Errors, output);
+        },
+        else => @compileError("Unable to stringify type '" ++ @typeName(T) ++ "'"),
+    }
+    unreachable;
+}
+
+fn teststringify(expected: []const u8, value: var) !void {
+    const TestStringifyContext = struct {
+        expected_remaining: []const u8,
+        fn testStringifyWrite(context: *@This(), bytes: []const u8) !void {
+            if (context.expected_remaining.len < bytes.len) {
+                std.debug.warn(
+                    \\====== expected this output: =========
+                    \\{}
+                    \\======== instead found this: =========
+                    \\{}
+                    \\======================================
+                , .{
+                    context.expected_remaining,
+                    bytes,
+                });
+                return error.TooMuchData;
+            }
+            if (!mem.eql(u8, context.expected_remaining[0..bytes.len], bytes)) {
+                std.debug.warn(
+                    \\====== expected this output: =========
+                    \\{}
+                    \\======== instead found this: =========
+                    \\{}
+                    \\======================================
+                , .{
+                    context.expected_remaining[0..bytes.len],
+                    bytes,
+                });
+                return error.DifferentData;
+            }
+            context.expected_remaining = context.expected_remaining[bytes.len..];
+        }
+    };
+    var buf: [100]u8 = undefined;
+    var context = TestStringifyContext{ .expected_remaining = expected };
+    try stringify(value, StringifyOptions{}, &context, error{
+        TooMuchData,
+        DifferentData,
+    }, TestStringifyContext.testStringifyWrite);
+    if (context.expected_remaining.len > 0) return error.NotEnoughData;
+}
+
+test "stringify basic types" {
+    try teststringify("false", false);
+    try teststringify("true", true);
+    try teststringify("null", @as(?u8, null));
+    try teststringify("null", @as(?*u32, null));
+    try teststringify("42", 42);
+    try teststringify("4.2e+01", 42.0);
+    try teststringify("42", @as(u8, 42));
+    try teststringify("42", @as(u128, 42));
+    try teststringify("4.2e+01", @as(f32, 42));
+    try teststringify("4.2e+01", @as(f64, 42));
+}
+
+test "stringify string" {
+    try teststringify("\"hello\"", "hello");
+    try teststringify("\"with\\nescapes\\r\"", "with\nescapes\r");
+    try teststringify("\"with unicode\\u0001\"", "with unicode\u{1}");
+    try teststringify("\"with unicode\\u0080\"", "with unicode\u{80}");
+    try teststringify("\"with unicode\\u00ff\"", "with unicode\u{FF}");
+    try teststringify("\"with unicode\\u0100\"", "with unicode\u{100}");
+    try teststringify("\"with unicode\\u0800\"", "with unicode\u{800}");
+    try teststringify("\"with unicode\\u8000\"", "with unicode\u{8000}");
+    try teststringify("\"with unicode\\ud799\"", "with unicode\u{D799}");
+    try teststringify("\"with unicode\\ud800\\udc00\"", "with unicode\u{10000}");
+    try teststringify("\"with unicode\\udbff\\udfff\"", "with unicode\u{10FFFF}");
+}
+
+test "stringify tagged unions" {
+    try teststringify("42", union(enum) {
+        Foo: u32,
+        Bar: bool,
+    }{ .Foo = 42 });
+}
+
+test "stringify struct" {
+    try teststringify("{\"foo\":42}", struct {
+        foo: u32,
+    }{ .foo = 42 });
+}
+
+test "stringify struct with void field" {
+    try teststringify("{\"foo\":42}", struct {
+        foo: u32,
+        bar: void = {},
+    }{ .foo = 42 });
+}
+
+test "stringify array of structs" {
+    const MyStruct = struct {
+        foo: u32,
+    };
+    try teststringify("[{\"foo\":42},{\"foo\":100},{\"foo\":1000}]", [_]MyStruct{
+        MyStruct{ .foo = 42 },
+        MyStruct{ .foo = 100 },
+        MyStruct{ .foo = 1000 },
+    });
+}
+
+test "stringify struct with custom stringifier" {
+    try teststringify("[\"something special\",42]", struct {
+        foo: u32,
+        const Self = @This();
+        pub fn jsonStringify(
+            value: Self,
+            options: StringifyOptions,
+            context: var,
+            comptime Errors: type,
+            comptime output: fn (@TypeOf(context), []const u8) Errors!void,
+        ) !void {
+            try output(context, "[\"something special\",");
+            try stringify(42, options, context, Errors, output);
+            try output(context, "]");
+        }
+    }{ .foo = 42 });
+}