json5 API reference Changelog View on GitHub

JSON5 in Zig: unquoted keys, single quotes, hex numbers, NaN, and comments.

Zig has no JSON5 parser in std. This one reads a JSON5 document into the std.json.Value tree you already walk, so a tree moves between this parser and std.json without a conversion. Pass a type to parseFromSlice when you want one, and Stringify.value writes the tree or any type std.json knows back as strict JSON.

It reads the syntax JSON leaves out: // and /* */ comments, a trailing comma, single-quoted and multi-line strings, unquoted names, 0x numbers, a leading +, a leading or trailing decimal point, NaN, and Infinity.

The API reference documents every public symbol.

Getting started

Install

zig fetch --save https://baskingspot.dev/json5/v0.1.0.tar.gz

Add the module in build.zig:

const json5 = b.dependency("json5", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("json5", json5.module("json5"));

json5 builds with Zig 0.16 only.

Import it

const json5 = @import("json5");

std.json keeps its name, so json5 and std.json can share a file.

Read the examples

The examples this page embeds are in docs/examples/. just test compiles and runs them with the module's own tests and the corpus, so no example can show code that does not build.

Reading a value

Parse one document

const std = @import("std");
const json5 = @import("json5");

const source =
    \\// The vhost entry.
    \\{
    \\    name: 'gateway',
    \\    port: 8080,
    \\    routes: ['/api', '/health',],
    \\    debug: false,
    \\}
;

test "parse a document into the std.json tree" {
    var document = try json5.parseFromSlice(std.json.Value, std.testing.allocator, source, .{});
    defer document.deinit();

    const root = document.value.object;
    try std.testing.expectEqualStrings("gateway", root.get("name").?.string);
    try std.testing.expectEqual(@as(i64, 8080), root.get("port").?.integer);
    try std.testing.expectEqual(false, root.get("debug").?.bool);

    const routes = root.get("routes").?.array;
    try std.testing.expectEqual(@as(usize, 2), routes.items.len);
    try std.testing.expectEqualStrings("/health", routes.items[1].string);
}

test "a repeated name keeps its first position and its last value" {
    var document = try json5.parseFromSlice(
        std.json.Value,
        std.testing.allocator,
        "{ a: 1, b: 2, a: 3 }",
        .{},
    );
    defer document.deinit();

    const root = document.value.object;
    try std.testing.expectEqual(@as(usize, 2), root.count());
    try std.testing.expectEqual(@as(i64, 3), root.get("a").?.integer);
}

parseFromSlice copies every string, object, and array into an arena that the Parsed owns, so the source may be freed as soon as it returns. document.deinit() frees the arena.

A document that breaks the grammar is error.SyntaxError. Pass a Diagnostics and it holds the span, line, byte column, and message of the first error:

const std = @import("std");
const json5 = @import("json5");

test "a bad document comes back with its position" {
    const source = "{\n    name: 'gateway',\n    port: ,\n}";

    var diagnostics: json5.Diagnostics = undefined;
    try std.testing.expectError(error.SyntaxError, json5.parseFromSlice(
        std.json.Value,
        std.testing.allocator,
        source,
        .{ .diagnostics = &diagnostics, .json = .{} },
    ));

    try std.testing.expectEqual(@as(u32, 3), diagnostics.line);
    try std.testing.expectEqual(@as(u32, 11), diagnostics.column);
    try std.testing.expectEqualStrings("expected a value", diagnostics.message());
}

The diagnostics are untouched when the parse succeeds and on error.OutOfMemory.

Walk the tree

Value is std.json.Value, so the tags are the ones std uses: .null, .bool, .integer, .float, .string, .array, and .object. An object is a std.json.ObjectMap and an array is a std.json.Array.

Member order is document order. A repeated name keeps its first position and its last value, which is what a JavaScript object does.

A number literal with no fraction and no exponent is .integer when it fits i64; every other number is .float, including a hexadecimal value past i64 and -0. .number_string is never set.

Typed reading

Parse into a struct

The first argument is the destination type.

const std = @import("std");
const json5 = @import("json5");

const Config = struct {
    name: []const u8,
    retry: ?u32 = null,
    tags: []const []const u8,
};

test "read a config into a struct" {
    const source =
        \\{
        \\    name: 'gateway',
        \\    failover: true,
        \\    tags: ['edge', 'internal'],
        \\}
    ;

    var config = try json5.parseFromSlice(Config, std.testing.allocator, source, .{
        // `failover` is not a field of Config, and the document is allowed to
        // carry it.
        .diagnostics = null,
        .json = .{ .ignore_unknown_fields = true },
    });
    defer config.deinit();

    try std.testing.expectEqualStrings("gateway", config.value.name);
    try std.testing.expectEqual(@as(?u32, null), config.value.retry);
    try std.testing.expectEqual(@as(usize, 2), config.value.tags.len);
    try std.testing.expectEqualStrings("internal", config.value.tags[1]);
}

The parser builds a std.json.Value tree, then std.json.parseFromValue turns that tree into Config. T can be anything std accepts: a struct, a tagged union, an enum, an optional, a slice, or a type with its own jsonParseFromValue. When T is std.json.Value, the second pass is skipped.

ParseOptions.json is a std.json.ParseOptions, used for the second pass. The defaults are std's. An unknown field is error.UnknownField, a repeated field is error.DuplicateField, and a field with a default value may be missing.

What std.json accepts

The mapping is std's parseFromValue. Struct fields go by name, a tagged union is an object with the tag as its key, an enum is its tag name or integer, an optional is null or the payload, and a slice is an array. A type that declares

pub fn jsonParseFromValue(
    allocator: std.mem.Allocator,
    source: std.json.Value,
    options: std.json.ParseOptions,
) !@This()

is decoded by that method instead.

parseFromSliceLeaky(T, arena.allocator(), source, .init) is the same parse into an arena the caller owns. The arena must stay at the same address while the value lives, because a std.json.Value tree's arrays hold the allocator built from it.

Writing

Write a value

const std = @import("std");
const json5 = @import("json5");

test "write the tree as strict JSON" {
    const source =
        \\{
        \\    name: 'gateway',
        \\    retry: 3,
        \\}
    ;

    var document = try json5.parseFromSlice(std.json.Value, std.testing.allocator, source, .{});
    defer document.deinit();

    var output: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();
    try json5.Stringify.value(document.value, .{
        .allow_nonfinite = true,
        .whitespace = .indent_2,
        .emit_null_optional_fields = true,
        .emit_strings_as_arrays = false,
        .escape_unicode = false,
        .emit_nonportable_numbers_as_strings = false,
    }, &output.writer);

    try std.testing.expectEqualStrings(
        \\{
        \\  "name": "gateway",
        \\  "retry": 3
        \\}
    , output.written());
}

Stringify.value takes the types std.json.Stringify.value takes: null, bool, integers, floats, enums, tagged unions, optionals, arrays, slices, vectors, structs, tuples, and any type with a jsonStringify method. The output is strict JSON. Options sets whitespace, escape_unicode, allow_nonfinite, and std's emit_* options.

Non-finite floats

JSON cannot spell NaN or Infinity, so the writer emits the JSON5 form: NaN, Infinity, or -Infinity. With allow_nonfinite = false, a non-finite float is error.NonFiniteFloat.

A finite float that is integral is written with a trailing .0, as in 1.0, so it reads back as a float. The trailing .0 and the spelling of a non-finite value are the two places the output differs from std.json.

The jsonStringify protocol

const std = @import("std");
const json5 = @import("json5");

const Release = struct {
    major: u16,
    minor: u16,

    pub fn jsonStringify(self: Release, jws: anytype) !void {
        try jws.print("\"{d}.{d}\"", .{ self.major, self.minor });
    }
};

test "a type with jsonStringify decides its own form" {
    var output: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();
    try json5.Stringify.value(Release{ .major = 0, .minor = 2 }, .{}, &output.writer);

    try std.testing.expectEqualStrings("\"0.2\"", output.written());
}

The writer's methods are beginObject, endObject, objectField, objectFieldRaw, beginObjectFieldRaw, endObjectFieldRaw, beginArray, endArray, write, print, beginWriteRaw, and endWriteRaw. std.json.Value uses the same protocol, which is how the tree is written.

Identifiers

The ASCII subset

IdentifierStart is $, _, or an ASCII letter. IdentifierPart adds the digits. Any non-ASCII code point is accepted in either position as well, which is wider than ES5.1. Ümläüt, aδ, $x, and _ are names, and so is ☃. A leading digit is a syntax error. # Limits states what the wider rule costs.

Escapes

A \uXXXX in a name is decoded before the name rule is applied. A high surrogate must be followed by a low surrogate, and the pair decodes to one code point, so \uD801\uDC00 is U+10400 and can start a name. A lone surrogate is a syntax error.

Conformance

The corpus

test/json5-tests/ vendors json5-tests at commit ceb24d4: 26 .json documents, 57 .json5 documents, 6 .js files, and 25 .txt files. just test runs all 114.

What the runner checks

Every .json case parses, and every one with a std.json tree matches it. The exception is comments/irregular-block-comment.json, an upstream file with the wrong extension that holds a block comment. Every .json5 case parses and round-trips through the writer. Every .js and .txt case fails with error.SyntaxError and a diagnostic inside the source.

Limits