    const std = @import("std");
    const mem = std.mem;
    const assert = std.debug.assert;

    pub const StringifyOptions = struct {
        pub const Whitespace = struct {
            /// How many indentation levels deep are we?
            indent_level: usize = 0,

            /// What character(s) should be used for indentation?
            indent: union(enum) {
                space: u8,
                tab: void,
                none: void,
            } = .{ .space = 4 },

            /// After a colon, should whitespace be inserted?
            separator: bool = true,

            pub fn outputIndent(
                whitespace: @This(),
                out_stream: anytype,
            ) @TypeOf(out_stream).Error!void {
                var char: u8 = undefined;
                var n_chars: usize = undefined;
                switch (whitespace.indent) {
                    .space => |n_spaces| {
                        char = ' ';
                        n_chars = n_spaces;
                    },
                    .tab => {
                        char = '\t';
                        n_chars = 1;
                    },
                    .none => return,
                }
                try out_stream.writeByte('\n');
                n_chars *= whitespace.indent_level;
                try out_stream.writeByteNTimes(char, n_chars);
            }
        };

        /// Controls the whitespace emitted
        whitespace: Whitespace = .{ .indent = .none, .separator = false },

        /// Should optional fields with null value be written?
        emit_null_optional_fields: bool = true,

        string: StringOptions = StringOptions{ .String = .{} },

        /// Should []u8 be serialised as a string? or an array?
        pub const StringOptions = union(enum) {
            Array,
            String: StringOutputOptions,

            /// String output options
            const StringOutputOptions = struct {
                /// Should '/' be escaped in strings?
                escape_solidus: bool = false,

                /// Should unicode characters be escaped in strings?
                escape_unicode: bool = false,
            };
        };
    };

    fn outputUnicodeEscape(
        codepoint: u21,
        out_stream: anytype,
    ) !void {
        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 out_stream.writeAll("\\u");
            try std.fmt.formatIntValue(codepoint, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, out_stream);
        } else {
            assert(codepoint <= 0x10FFFF);
            // 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 = @as(u16, @intCast((codepoint - 0x10000) >> 10)) + 0xD800;
            const low = @as(u16, @intCast(codepoint & 0x3FF)) + 0xDC00;
            try out_stream.writeAll("\\u");
            try std.fmt.formatIntValue(high, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, out_stream);
            try out_stream.writeAll("\\u");
            try std.fmt.formatIntValue(low, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, out_stream);
        }
    }

    /// Write `string` to `writer` as a JSON encoded string.
    pub fn encodeJsonString(string: []const u8, options: StringifyOptions, writer: anytype) !void {
        try writer.writeByte('\"');
        try encodeJsonStringChars(string, options, writer);
        try writer.writeByte('\"');
    }

    /// Write `chars` to `writer` as JSON encoded string characters.
    pub fn encodeJsonStringChars(chars: []const u8, options: StringifyOptions, writer: anytype) !void {
        var i: usize = 0;
        while (i < chars.len) : (i += 1) {
            switch (chars[i]) {
                // normal ascii character

                0x20...0x21, 0x23...0x2E, 0x30...0x5B, 0x5D...0x7F => |c| try writer.writeByte(c),
                // only 2 characters that *must* be escaped

                '\\' => try writer.writeAll("\\\\"),
                '\"' => try writer.writeAll("\\\""),
                // solidus is optional to escape

                '/' => {
                    if (options.string.String.escape_solidus) {
                        try writer.writeAll("\\/");
                    } else {
                        try writer.writeByte('/');
                    }
                },
                // control characters with short escapes

                // TODO: option to switch between unicode and 'short' forms?

                0x8 => try writer.writeAll("\\b"),
                0xC => try writer.writeAll("\\f"),
                '\n' => try writer.writeAll("\\n"),
                '\r' => try writer.writeAll("\\r"),
                '\t' => try writer.writeAll("\\t"),
                else => {
                    const ulen = std.unicode.utf8ByteSequenceLength(chars[i]) catch unreachable;
                    // control characters (only things left with 1 byte length) should always be printed as unicode escapes

                    if (ulen == 1 or options.string.String.escape_unicode) {
                        const codepoint = std.unicode.utf8Decode(chars[i..][0..ulen]) catch unreachable;
                        try outputUnicodeEscape(codepoint, writer);
                    } else {
                        try writer.writeAll(chars[i..][0..ulen]);
                    }
                    i += ulen - 1;
                },
            }
        }
    }

    pub fn stringify(
        value: anytype,
        options: StringifyOptions,
        out_stream: anytype,
    ) !void {
        const T = @TypeOf(value);
        switch (@typeInfo(T)) {
            .Float, .ComptimeFloat => {
                return std.fmt.formatFloatScientific(value, std.fmt.FormatOptions{}, out_stream);
            },
            .Int, .ComptimeInt => {
                return std.fmt.formatIntValue(value, "", std.fmt.FormatOptions{}, out_stream);
            },
            .Bool => {
                return out_stream.writeAll(if (value) "true" else "false");
            },
            .Null => {
                return out_stream.writeAll("null");
            },
            .Optional => {
                if (value) |payload| {
                    return try stringify(payload, options, out_stream);
                } else {
                    return try stringify(null, options, out_stream);
                }
            },
            .Enum => {
                if (comptime std.meta.trait.hasFn("jsonStringify")(T)) {
                    return value.jsonStringify(options, out_stream);
                }

                @compileError("Unable to stringify enum '" ++ @typeName(T) ++ "'");
            },
            .Union => {
                if (comptime std.meta.trait.hasFn("jsonStringify")(T)) {
                    return value.jsonStringify(options, out_stream);
                }

                const info = @typeInfo(T).Union;
                if (info.tag_type) |UnionTagType| {
                    try out_stream.writeByte('{');
                    var child_options = options;
                    child_options.whitespace.indent_level += 1;
                    inline for (info.fields) |u_field| {
                        if (value == @field(UnionTagType, u_field.name)) {
                            try child_options.whitespace.outputIndent(out_stream);
                            try encodeJsonString(u_field.name, options, out_stream);
                            try out_stream.writeByte(':');
                            if (child_options.whitespace.separator) {
                                try out_stream.writeByte(' ');
                            }
                            if (u_field.type == void) {
                                try out_stream.writeAll("{}");
                            } else {
                                try stringify(@field(value, u_field.name), child_options, out_stream);
                            }
                            break;
                        }
                    } else {
                        unreachable; // No active tag?

                    }
                    try options.whitespace.outputIndent(out_stream);
                    try out_stream.writeByte('}');
                    return;
                } else {
                    @compileError("Unable to stringify untagged union '" ++ @typeName(T) ++ "'");
                }
            },
            .Struct => |S| {
                if (comptime std.meta.trait.hasFn("jsonStringify")(T)) {
                    return value.jsonStringify(options, out_stream);
                }

                try out_stream.writeByte(if (S.is_tuple) '[' else '{');
                var field_output = false;
                var child_options = options;
                child_options.whitespace.indent_level += 1;
                inline for (S.fields) |Field| {
                    // don't include void fields

                    if (Field.type == void) continue;

                    var emit_field = true;

                    // don't include optional fields that are null when emit_null_optional_fields is set to false

                    if (@typeInfo(Field.type) == .Optional) {
                        if (options.emit_null_optional_fields == false) {
                            if (@field(value, Field.name) == null) {
                                emit_field = false;
                            }
                        }
                    }

                    if (emit_field) {
                        if (!field_output) {
                            field_output = true;
                        } else {
                            try out_stream.writeByte(',');
                        }
                        try child_options.whitespace.outputIndent(out_stream);
                        if (!S.is_tuple) {
                            try encodeJsonString(Field.name, options, out_stream);
                            try out_stream.writeByte(':');
                            if (child_options.whitespace.separator) {
                                try out_stream.writeByte(' ');
                            }
                        }
                        try stringify(@field(value, Field.name), child_options, out_stream);
                    }
                }
                if (field_output) {
                    try options.whitespace.outputIndent(out_stream);
                }
                try out_stream.writeByte(if (S.is_tuple) ']' else '}');
                return;
            },
            .ErrorSet => return stringify(@as([]const u8, @errorName(value)), options, out_stream),
            .Pointer => |ptr_info| switch (ptr_info.size) {
                .One => switch (@typeInfo(ptr_info.child)) {
                    .Array => {
                        const Slice = []const std.meta.Elem(ptr_info.child);
                        return stringify(@as(Slice, value), options, out_stream);
                    },
                    else => {
                        // TODO: avoid loops?

                        return stringify(value.*, options, out_stream);
                    },
                },
                .Many, .Slice => {
                    if (ptr_info.size == .Many and ptr_info.sentinel == null)
                        @compileError("unable to stringify type '" ++ @typeName(T) ++ "' without sentinel");
                    const slice = if (ptr_info.size == .Many) mem.span(value) else value;

                    if (ptr_info.child == u8 and options.string == .String and std.unicode.utf8ValidateSlice(slice)) {
                        try encodeJsonString(slice, options, out_stream);
                        return;
                    }

                    try out_stream.writeByte('[');
                    var child_options = options;
                    child_options.whitespace.indent_level += 1;
                    for (slice, 0..) |x, i| {
                        if (i != 0) {
                            try out_stream.writeByte(',');
                        }
                        try child_options.whitespace.outputIndent(out_stream);
                        try stringify(x, child_options, out_stream);
                    }
                    if (slice.len != 0) {
                        try options.whitespace.outputIndent(out_stream);
                    }
                    try out_stream.writeByte(']');
                    return;
                },
                else => @compileError("Unable to stringify type '" ++ @typeName(T) ++ "'"),
            },
            .Array => return stringify(&value, options, out_stream),
            .Vector => |info| {
                const array: [info.len]info.child = value;
                return stringify(&array, options, out_stream);
            },
            else => @compileError("Unable to stringify type '" ++ @typeName(T) ++ "'"),
        }
        unreachable;
    }

    // Same as `stringify` but accepts an Allocator and stores result in dynamically allocated memory instead of using a Writer.

    // Caller owns returned memory.

    pub fn stringifyAlloc(allocator: std.mem.Allocator, value: anytype, options: StringifyOptions) ![]const u8 {
        var list = std.ArrayList(u8).init(allocator);
        errdefer list.deinit();
        try stringify(value, options, list.writer());
        return list.toOwnedSlice();
    }

    test {
        _ = @import("./stringify_test.zig");
    }