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.
- Reading a value:
parseFromSlice(std.json.Value, ...)returns aParsed, andDiagnosticsgives the span, line, byte column, and message of the first syntax error. - Typed reading: the first argument is any type
std.json.parseFromValueaccepts, includingstd.json.Value;ParseOptions.jsonholds std's options for the second pass. - Writing:
Stringify.valuetakes the typesstd.json.Stringify.valuetakes, andOptionssets indentation, ASCII escaping, and what happens toNaN. - Identifiers: a name is ASCII IdentifierStart and IdentifierPart plus any non-ASCII code point, and a
\uXXXXescape or a surrogate pair decodes first. - Conformance: 114 cases from
json5-tests, run byjust test. - Limits: a parser that wants the whole source and a writer that normalizes the document.
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
- The parser wants the whole source in memory. There is no streaming entry point.
Valuehas no place for comments or extra whitespace, so the writer emits a normalized document.- A map without a
jsonStringifymethod is written as a struct of its fields, because that is whatstd.json.Stringifydoes. - The exact ES5.1 identifier categories: a name accepts ASCII plus any non-ASCII code point, and the categories would need a Unicode property table this package does not vendor.
- Objects and arrays may nest 256 deep. The parser returns
error.SyntaxErrorpast that, and the writer returnserror.NestingTooDeep.