Sign in

    moonjson

    The JSON family for MoonBit.

    json
    cbor
    moonbit
    Download zip
    Version
    0.4.1
    License
    Apache-2.0
    Last updated
    2 days ago
    Downloads
    1K

    No dependencies

    #moonjson

    The JSON family for MoonBit: one tree, several ways of writing it.

    let config = @json.loads("{\"host\":\"localhost\",\"port\":8080}")
    @json.dumps(config) // {"host":"localhost","port":8080}
    @json.dumps(config, indent=2) // over several lines

    // The same document, written the way a settings file is written.
    @jsonc.loads("{\"port\": 8080, // the one we bound\n}")

    // One place in a document, named (RFC 6901).
    @pointer.get(config, "/host") // "localhost"

    Run moon run examples/tour for the whole surface in one go.

    #The surface

    One set of verbs across the family:

    text → treebytes → treetree → texttree → bytes
    one documentloadsloaddumpsdump
    a stream of themloads_allload_alldumps_alldump_all

    The s is on the form that takes or answers a string. The plural set exists exactly where the format defines a stream — lines is a stream and has only those; json, jsonc and json5 define one document and have only the singular set. moonyaml follows the same rule and has both, because --- makes both meaningful.

    Not from_str and to_string: to_string already means Show::to_string in MoonBit, and @json.to_string(x) beside x.to_string() is a sentence that reads two ways.

    // Read a document, change one field, write it back.
    let cfg = @jsonc.loads(text[:])
    let cfg = @pointer.set(cfg, "/spec/replicas", Json::number(5))
    @json.dumps(cfg, indent=2)

    #Packages

    PackageWhatSpecification
    jsonJSON, exactlyRFC 8259, ECMA-404
    jsoncJSON with comments and trailing commaswhat VS Code accepts
    json5JSON5: unquoted names, single quotes, hexadecimal, Infinityspec.json5.org
    linesJSON Lines / NDJSON: one document to a linejsonlines.org
    pointerJSON PointerRFC 6901
    patchJSON Patch: add, remove, replace, move, copy, testRFC 6902
    mergeJSON Merge PatchRFC 7396
    pathJSONPath: a query language over the tree, filters and allRFC 9535
    jcsCanonical JSON: one document, one sequence of bytesRFC 8785
    cborCBOR, the binary encoding JSON's own working group wroteRFC 8949
    msgpackMessagePackmsgpack.org
    bsonBSON, what MongoDB stores and sendsbsonspec.org
    moonjsonThe scanner the dialects share, the writer, and Refused—

    Each text dialect has the same four faces — loads, load, dumps, dump — so learning one is learning all of them. Writing is always strict JSON, whatever the document was written in. A binary format has the two that take bytes: load and dump.

    merge is the one without them: a merge patch has no syntax of its own — the patch document is a JSON document, read with @json.loads — so the package holds the evaluation and nothing else.

    #One tree, every notation

    A document read from JSON5 can be written as CBOR, read back from CBOR, and written as JSON again, because all of them read into the same tree. That is the whole design: the formats are notations, and Json is what they all say.

    Four things JSON has no syntax for are carried in markers, so that a value that came from BSON or CBOR survives a trip through JSON:

    ValueMarker
    a byte string{"$bytes": "0102"} — lowercase hex, no separators
    a tag or an extension type{"$tag": 1, "value": …}, {"$ext": 1, "data": "0102"}
    undefined{"$undefined": true}
    a map whose keys are not strings{"$map": [[1, 2]]}

    BSON's own types have markers of their own — {"$oid": …}, {"$date": …}, {"$decimal128": …} and the rest — because a date is not a number and an ObjectId is not a string. Writing reads the same markers back, so nothing is lost in either direction. A marker name is reserved: an object whose only member is $oid is an ObjectId, not a document that happens to have that member.

    let doc = @json5.loads("{host:'localhost',port:8080,tls:false}")
    let body = @cbor.dump(doc) // the same tree, as bytes
    let doc = @cbor.load(body) // and back
    @json.dumps(doc) // {"host":"localhost","port":8080,"tls":false}

    #Configuration

    How to read is a value. Flavor carries both what syntax a dialect accepts and what it does at the edges — how deep the nesting may go, what a lone surrogate means, what a repeated member name means — and every reader takes one:

    @json.loads(text) // strict JSON
    @jsonc.loads(text) // the same, plus comments
    @json.loads(text, flavor=@moonjson.Flavor::new(depth=100))
    @json.loads(text, flavor={ ..@moonjson.strict, duplicates: Reject })

    Two layers, the later overriding the earlier: the dialect's preset < the flavor you pass. The two ways of building one — the constructor and the record update — are the same thing, and a test asserts it.

    There are no per-call mirrors of the individual settings here, and the reason is the arithmetic: four dialects times four entry points is sixteen signatures, and a setting mirrored into all of them costs sixteen lines every time one is added. A dialect is chosen per use site rather than per call, so the record is where it belongs. Writing is the other way round, so its three settings are arguments:

    @json.dumps(value) // compact
    @json.dumps(value, indent=2) // over several lines
    @json.dumps(value, ascii=true) // escape everything above U+007E
    @json.dumps(value, sort=true) // members in order of name

    #The defaults

    SettingDefaultWhy that one
    depth500A hundred thousand open brackets is a denial of service, not a document
    surrogatesrefuseThe case is implementation-defined, so the safer side is the default: a lone half cannot be written back out
    duplicatesLastThe behaviour most readers already have
    bomskippedRFC 8259 §8.1 forbids writing one and says nothing about reading one; refusing would reject half of what Windows tooling produces
    asciioffA UTF-8 body is the normal case now
    sortoffThe order a document was written in is information; a diff is the reason to discard it, and a diff can ask
    CBOR deterministiconRFC 8949 §4.2 recommends it, and a hash or a signature over an encoding needs it. Off still writes valid CBOR, only longer
    CBOR depth500The same as the text reader, for the same reason
    MessagePack floatsDoubleNo precision is lost unless asked
    BSON depth500Documents nest inside documents exactly as brackets nest inside brackets

    #The tree

    The data model is core's Json, not one of our own. Anything that already speaks @json.Json speaks this, with no conversion.

    A number keeps the text it was written as, so a document read and written again comes back unchanged — including a twenty-digit integer and 1e400, which a double cannot hold and which a parser that goes through one silently rewrites. A number JSON has no syntax for, which JSON5 can produce, is written as null, as every JavaScript implementation writes it.

    Members keep their order.

    #Changing a document

    Three ways, for three kinds of change. One place is named with a pointer (RFC 6901); a set of changes that must all hold or none is a patch (RFC 6902); and a change that looks like the document itself is a merge patch (RFC 7396).

    // One field, and nothing else changes.
    let cfg = @pointer.set(cfg, "/spec/replicas", Json::number(5))

    // Six operations, applied in order — or, if one fails, not at all.
    let ops = @patch.loads(
    "[{\"op\":\"test\",\"path\":\"/spec/replicas\",\"value\":1},{\"op\":\"replace\",\"path\":\"/spec/replicas\",\"value\":5}]"
    )
    @patch.apply(cfg, ops)

    // A patch that looks like the document: a member that is not there is added,
    // one that is there is replaced, and a null takes one away.
    @merge.merge(cfg, @json.loads("{\"spec\":{\"replicas\":5,\"image\":null}}"))

    A patch is read into Operations rather than left as a tree of objects, so a misspelled op is an error rather than a document that quietly does nothing, and the pointers inside it are the same ones @pointer reads.

    #Failures

    Reading raises Refused, which says what went wrong and where — offset, line and column:

    try @json.loads(source) catch {
    e => match e.at() {
    Some(spot) => println("line \{spot.line}, column \{spot.column}")
    None => println(e) // a pointer or a patch, which has a path instead
    }
    }

    One type for the whole family, so there is one thing to catch: the scanner's refusals, the pointer's Missing and BadIndex, the patch's TestFailed and IntoChild, and Unwritable for a value a writer cannot put on paper. A failure in a binary format answers None from at() and carries a byte offset in its variant instead, because a line number invented for a byte string would be worse than none.

    Nesting is refused past 500 levels rather than taken to the stack: a document of a hundred thousand open brackets is a denial of service, not a document.

    A lone half of a surrogate pair is refused. It is not a character, it cannot be written back out, and any value put in its place would be a guess.

    #Install

    moon add moonbitstack/moonjson

    #Licence

    Apache-2.0.

    Refused

    pub(all) suberror Refused {
    Unexpected(At, Char)
    Truncated(At)
    BadEscape(At)
    BadNumber(At)
    Trailing(At)
    TooDeep(At)
    BadUtf8(At)
    Repeated(At, String)
    Short(at~ : Int)
    Extra(at~ : Int)
    Bad(at~ : Int, why~ : String)
    Unwritable(why~ : String)
    Syntax(path~ : String, at~ : Int)
    Missing(path~ : String, token~ : String)
    NotContainer(path~ : String, token~ : String)
    BadIndex(path~ : String, token~ : String)
    Document(why~ : String)
    Operation(at~ : Int, why~ : String)
    TestFailed(path~ : String)
    IntoChild(from~ : String, path~ : String)
    Root(path~ : String)
    } derive(Eq,
    Debug
    )

    Why a document was refused, and where.

    One error type for the whole family — reading text, reading bytes, walking a pointer, applying a patch, writing a value a format cannot carry — so that a caller has one thing to catch and one place to look. A variant that has a position carries the [At] it was found at; a variant about a pointer or a patch names the pointer itself, because a move and a copy each carry two and an error that did not say which one would be half an error.

    Refused::at

    fn Refused::at(self : Refused) -> At?

    Where the failure happened, when it happened in text.

    None is not a failure to say: a pointer or a patch that was refused has a path rather than a line, and a line number invented for it would be worse than no line number at all.

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::to_repr

    pub(all) struct At {
    at : Int
    line : Int
    column : Int
    } derive(Eq,
    Debug
    )

    Where something went wrong in a document that arrived as text.

    The offset is in UTF-16 code units from the start, which is what a [StringView] indexes by; line and column are one-based, because that is how every editor counts and an error a person cannot find in their file is half an error.

    At::equal

    fn At::equal(At, At) -> Bool

    At::not_equal

    fn At::not_equal(x : At, y : At) -> Bool

    At::to_repr

    Duplicates

    pub(all) enum Duplicates {
    Last
    First
    Reject
    } derive(Eq,
    Debug
    )

    What a repeated member name means.

    Every mainstream implementation keeps the last one — JavaScript, Python, Go and serde all do — so that is the default. The other two are here because a document with two "id" members is usually a mistake, and a reader that wants to hear about it should not have to write its own parser.

    Duplicates::equal

    fn Duplicates::equal(Duplicates, Duplicates) -> Bool

    Duplicates::not_equal

    fn Duplicates::not_equal(x : Duplicates, y : Duplicates) -> Bool

    Flavor

    pub(all) struct Flavor {
    comments : Bool
    trailing_commas : Bool
    unquoted_keys : Bool
    single_quotes : Bool
    extra_numbers : Bool
    extra_escapes : Bool
    extra_space : Bool
    depth : Int
    surrogates : Bool
    duplicates : Duplicates
    } derive(Eq,
    Debug
    )

    How to read a document: what syntax to accept, and what to do at the edges.

    The dialects in this family differ only in their syntax — the tree they produce is the same — so the scanner is written once and each dialect is a set of switches. A dialect outside this repository can be described the same way without forking anything.

    The last three fields are not syntax but policy. They are here rather than as arguments because they are set once for a deployment and because this family has sixteen entry points: a policy added here costs one line, and a policy added to every signature costs sixteen.

    Flavor::equal

    fn Flavor::equal(Flavor, Flavor) -> Bool

    Flavor::new

    fn Flavor::new(comments? : Bool, trailing_commas? : Bool, unquoted_keys? : Bool, single_quotes? : Bool, extra_numbers? : Bool, extra_escapes? : Bool, extra_space? : Bool, depth? : Int, surrogates? : Bool, duplicates? : Duplicates) -> Flavor

    Build a dialect by naming the parts that differ from strict JSON.

    The same thing can be written { ..@moonjson.strict, depth: 100 }; this form exists because a named argument reads better when several parts differ.

    Flavor::not_equal

    fn Flavor::not_equal(x : Flavor, y : Flavor) -> Bool

    Flavor::to_repr

    commented

    let commented : Flavor

    JSON with comments and trailing commas — what editors and their configuration files have settled on, tsconfig.json being the best-known.

    dump

    fn dump(value : Json, indent? : Int, ascii? : Bool, sort? : Bool) -> Bytes

    The same, as UTF-8 bytes — the form a body goes onto a wire in.

    dumps

    fn dumps(value : Json, indent? : Int, ascii? : Bool, sort? : Bool) -> String

    Write a tree as strict JSON, whatever dialect it was read from.

    indent of zero, the default, writes the compact form with no space anywhere; anything larger writes one line per member with that many spaces a level, and a space after each colon.

    A number that came in as text goes back out as that text, so a document read and written again is unchanged. Only a number with no such text is formatted, and a number that is not finite — which JSON5 can produce and JSON cannot write — becomes null, as every JavaScript implementation does.

    load

    fn load(input : BytesView, flavor : Flavor, bom? : Bool) -> Json raise Refused

    Read one document from bytes, which is how one arrives over a wire. A leading byte-order mark is skipped by default. RFC 8259 §8.1 forbids writing one and says nothing about refusing to read one, and refusing would mean rejecting half the files Windows tooling produces.

    loads

    fn loads(input : StringView, flavor : Flavor) -> Json raise Refused

    Read one document in the given dialect.

    The whole input must be one value: anything after it, other than whitespace and — where the dialect allows them — comments, is [Trailing], not silently ignored. A parser that stops at the first value turns a truncated file into a plausible one.

    relaxed

    let relaxed : Flavor

    JSON5: everything ECMAScript 5 would have accepted.

    strict

    let strict : Flavor

    RFC 8259 and nothing more.