moontoml

    TOML for MoonBit, at the edition the file was written in, with a reading that keeps the document as it was.

    toml
    toml-0.4
    toml-0.5
    toml-1.0
    toml-1.1
    toml-test
    toml-parser
    toml-encoder
    toml-reader
    toml-writer
    config
    configuration
    manifest
    moon.mod
    parser
    serializer
    decoder
    encoder
    datetime
    rfc3339
    json
    edition
    dialect
    fidelity
    round-trip
    roundtrip
    lossless
    in-place
    edit
    document
    comment
    moonbit
    Download zip
    Version
    0.1.3
    License
    Apache-2.0
    Last updated
    9 hours ago
    Downloads
    1K

    moontoml


    moon add moonbitstack/moontoml

    A document goes in, a tree comes out. Write the tree back and the values are the same.

    let src = "title = \"field notes\"\nbeds = 4\n\n[soil]\nkind = \"loam\"\nph = 6.5\n"
    let doc = @moontoml.loads(src[:])
    assert_eq(
    @moontoml.dumps(doc),
    "title = \"field notes\"\nbeds = 4\n\n[soil]\nkind = \"loam\"\nph = 6.5\n",
    )

    #Two readings

    loads gives a Json tree: seven kinds of value, five of them in a form Json has. decode gives a tree of Value, which keeps every distinction the format makes. The difference is a date-time and a 64-bit integer, the two places where Json is narrower than TOML.

    TOMLloadsdecodeExample
    stringStringString(String)"loam"
    integerNumberInteger(Int64)42, 0x2A, 0o52, 0b101010
    floatNumberFloat(Real)1.5, 5e+22, inf, nan
    booleanTrue / FalseBoolean(Bool)true
    date-timeStringDatetime(Moment)1979-05-27T07:32:00Z
    arrayArrayArray(Array[Value])[1, 2, 3]
    tableObjectTable(Map[String, Value]){ x = 1 }

    Both readings accept the same documents and refuse the same ones; they differ only in what comes out.

    #The third reading: the document, kept

    A tree forgets the page it came from. decode_doc does not: it keeps the source, the items in the order they were read, and the comment or blank line before each one. Change one value in place and write the result back, and the rest of the document is byte for byte as it was.

    let doc = @moontoml.decode_doc("beds = 4 # the ones that get sun\nsoil = \"loam\"\n")
    assert_eq(doc.set(["beds"], @moontoml.Value::integer(6)), Some(true))
    // the comment and the order are the source; only the value moved
    assert_eq(doc.render(), "beds = 6 # the ones that get sun\nsoil = \"loam\"\n")

    get reaches a value by path and set replaces one in place; render answers the document. A path is the segments of a key, ["soil", "ph"] being the key under a [soil] header. When the path names nothing — a missing key, or a table where a value should stand — set answers nothing and the document is left as it was. This is the reading for moon.mod, a service config, a manifest: the diff is the one line that changed.

    #The editions

    The root reads TOML 1.0.0. The editions are subpackages with the same entry points, pinned to their grammar, because the editions differ by real rules:

    // 0.4 has no dotted keys, no hex or binary integers, no inf, and only the offset date-time.
    let src = "a.b = 1\n"
    let refused = try {
    let _ = @v04.loads(src[:])
    false
    } catch {
    _ => true
    }
    assert_eq(refused, true)

    // 0.5 adds dotted keys, prefixed integers, inf and nan, and the local date-time kinds.
    assert_eq(@v05.loads("a.b = 1\n"[:]).stringify(), "{\"a\":{\"b\":1}}")

    // 1.1 lets an inline table span lines and end with a comma.
    assert_eq(
    @v110.loads("p = { x = 1,\n y = 2,\n}"[:]).stringify(),
    "{\"p\":{\"x\":1,\"y\":2}}",
    )

    EditionThe grammar
    moonbitstack/moontoml1.0.0 — dotted keys, prefixed integers, mixed-type arrays, one-line inline tables
    …/moontoml/v1101.1 — inline tables that span lines with a trailing comma, \x and \e escapes, a time that may drop its seconds
    …/moontoml/v050.5 — dotted keys, prefixed integers, inf/nan, the local date-time kinds; arrays are one kind
    …/moontoml/v040.4 — no dotted keys, no prefixed integers, no inf/nan, only the offset date-time

    The same decode, loads, encode, dumps, decode_doc and Value in every one; only the edition moves. A Flavor::new(edition=…) on the root reads any of them without the subpackage.

    #Reading values

    A Json is read by shape. Object is a table, String a string, and a missing key is None.

    let doc = @moontoml.loads("title = \"field notes\"\nbeds = 4"[:])
    guard doc is Object(root) else { abort("a document is a table") }
    let title = match root.get("title") {
    Some(String(s)) => s
    _ => "?"
    }
    let beds = match root.get("beds") {
    Some(Number(n, ..)) => n.to_int()
    _ => 0
    }
    assert_eq((title, beds), ("field notes", 4))

    #Kinds, kept

    A Value is read by kind. A date is a Datetime and a string is a String, even when the string is written like a date:

    let doc = @moontoml.decode("port = 12000\nwhen = 1979-05-27\nname = \"1979-05-27\"\n")
    guard doc is Table(fields) else { abort("a document is a table") }
    guard fields.get("when") is Some(Datetime(m)) else { abort("a date") }
    assert_eq(m.to_string(), "1979-05-27")

    That is the whole point of the second reading: encode writes a String quoted and a Datetime bare, so a value keeps the kind it had, and no guess is made from the spelling.

    The four date-time kinds of the format are the four of Moment, which is moondate's type: an offset date-time, a local date-time, a local date and a local time. Integer holds all 64 bits, so a value past what a double holds exactly comes back as itself.

    #Writing it back

    dumps writes scalars at the level they are on and puts tables under their own headers. The order is the tree's own; sort=true orders keys by name.

    let doc = @moontoml.loads("b = 1\na = 2"[:])
    assert_eq(@moontoml.dumps(doc), "b = 1\na = 2\n")
    assert_eq(@moontoml.dumps(doc, sort=true), "a = 2\nb = 1\n")

    A document read and written again keeps its values, not its layout. TOML can spell the same tree several ways; this writer picks one.

    dumps starts from a Json tree, which cannot say whether a string was a date, so it leans on the spelling: a string that is a date-time is written as one. encode starts from a Value and needs no such reading. decode_doc keeps the layout itself, the Doc, and writes it back as it was.

    #The interface

    The subpackages v04, v05 and v110 expose the same names pinned to their edition, plus their own decode_doc.

    #What it reads

    Everything TOML 1.0.0 defines: bare, quoted and dotted keys; [table] and [[array of tables]] headers; the four string forms with their escapes and the line-ending backslash; integers in decimal, hexadecimal, octal and binary with digit-grouping underscores; floats with exponents and inf/nan; the four date-time types; arrays that span lines; and inline tables. The editions read the same document at their own rules, so a 0.5 file is refused of a 0.4 and a 1.1 file of a 1.0.0, and read in full at its own.

    #What it refuses

    TOML's redefinition rules, which are what make a file safe to append to:

    • A key defined twice.
    • A table header repeated.
    • A dotted key later opened by a header.
    • A key added to an inline table after its brace closed.
    • [[…]] over something that is not an array of tables.

    Also a leading zero on a multi-digit integer, an underscore not between two digits of one base, a float missing a digit on either side of its dot, a trailing comma in an inline table, a control character anywhere but a tab or a line break, a date-time that is not spelled the way the format spells one, and a second kind in an array at an edition before mixed ones — all of which the editions refuse.

    Every refusal carries an At. The ten kinds are Unexpected, Truncated, BadEscape, BadNumber, BadDate, Redefined, MixedArray, Trailing, TooDeep and BadUtf8.

    #Dates and integers

    Two places where a Json tree is narrower than the format, and what each reading does about them.

    Dates become text. loads reads a date-time as its RFC 3339 form, and writes it back bare rather than quoted, so a timestamp stays a timestamp. decode reads it as a Datetime, which is the four kinds of the format. A date that does not exist, like 1979-02-30, is refused by both.

    Integers keep their digits. decode holds an integer in 64 bits, so every value the format allows survives exactly. loads has only a double, which is exact to 2^53, so the digits a number was written as travel beside the value whenever RFC 8259 spells it the same way, and 9007199254740993 writes back as itself. A spelling RFC 8259 does not share — +99, 0xff, 1_000, inf — carries no text and is written from the value.

    #Conformance

    The reader and the writer are checked against toml-test, the format's own suite, at the editions it lists — 1.0.0 and 1.1.0 — over every file each edition names:

    SuiteEditionDocumentsPassing
    valid, read as the tree the suite expects1.0.0205205
    valid, written by the library and read back1.0.0205205
    invalid, refused1.0.0474474
    valid, read as the tree the suite expects1.1.0214214
    valid, written by the library and read back1.1.0214214
    invalid, refused1.1.0467467
    valid, read into the Doc and written back byte for byte1.0.0205205
    valid, read into the Doc and written back byte for byte1.1.0214214

    Nothing is skipped, and no case is expected to fail. The 0.4 and 0.5 editions have no toml-test file list of their own, so they are checked feature by feature against the edition they grow from: every thing 0.5 introduced — a dotted key, a prefixed integer, inf or nan, a local date-time — is refused of a 0.4 and read in full at a 0.5, and what 0.4 already allowed reads the same in both. That is the editions_wbtest in the conformance package. The corpus is generated into the test sources by scripts/gen_vectors.py, which pins the release and checks its digest, so the numbers above are the ones the tests assert on every backend.

    #Examples

    ExampleWhat it shows
    src/examples/basicread, change one value, write back — once as a tree, once as the document kept with its comments and order
    src/examples/integrationa site config read into a struct, changed, written back sorted, and a refusal reported by line

    moon run src/examples/basic moon run src/examples/integration

    #Licence

    Apache-2.0. See LICENSE.

    Refused

    pub(all) suberror Refused {
    Unexpected(At, Char)
    Truncated(At)
    BadEscape(At)
    BadNumber(At)
    BadDate(At)
    Redefined(At, String)
    MixedArray(At)
    Trailing(At)
    TooDeep(At)
    BadUtf8(At)
    } derive(Eq,
    Debug
    )

    Why a document was refused.

    Redefined is the rule that matters most: §1 says a key may be defined only once, and so may a table, so a second definition is an error rather than a value that wins. It is the rule that makes a TOML file safe to append to.
    impl Show for Refused

    Refused::at

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

    Where the refusal happened.

    Every variant carries a position, so a caller reporting a line does not have to match all ten of them to get at it.

    Refused::equal

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

    Refused::not_equal

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

    Refused::output

    fn Refused::output(self : Refused, logger : &Logger) -> Unit

    Refused::to_repr

    Refused::to_string

    fn Refused::to_string(self : Refused) -> String

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

    Where in the source something is: the offset in code units, and the line and column a person counts from one.
    impl Show for At

    At::equal

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

    At::not_equal

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

    At::output

    fn At::output(self : At, logger : &Logger) -> Unit

    At::to_repr

    At::to_string

    fn At::to_string(self : At) -> String

    Doc

    pub(all) struct Doc {
    src : String
    items : Array[Item]
    root : Value
    } derive(
    Debug
    )

    A document that remembers how it was written.

    The other entry points read a TOML document into a tree and forget the page it came from. This one keeps the page: the source text, the items in the order they were read, each with the bytes it occupied, and the tree they denote. Change one value in place and write the result back, and the rest of the document is byte for byte as it was — the comments, the blank lines, the order, and the spelling of every value but the one that moved.

    This is the reading for a file a person edits by hand — moon.mod, a service config, a manifest — where the diff should be the one line that changed.

    Doc::decode

    fn Doc::decode(src : StringView, flavor? : Flavor) -> Doc raise Refused

    Read a document into a Doc, which keeps it as it was written.

    flavor names the edition, the same as the other entry points. A document the edition refuses is refused the same way decode refuses it.

    Doc::get

    fn Doc::get(self : Doc, path : Array[String]) -> Value?

    The value a path names, or nothing when the path does not name a value.

    A path is the segments of a key as a person writes them: ["soil", "ph"] is the key under the table a [soil] header opens, and ["a", "b"] is the key written a.b.

    Doc::render

    fn Doc::render(self : Doc) -> String

    The document as it was read, with its changed values written back.

    Nothing changed and the answer is the source byte for byte. A value that moved is written where it occupied, the way this module writes one; the key, the separator and anything after the value on the line are the original text, and so is everything between the lines — the comments, the blank lines, the order.

    Doc::set

    fn Doc::set(self : Doc, path : Array[String], value : Value) -> Bool?

    Change the value a path names, in place.

    The path must name a value already in the document; when it names nothing — a missing key, or a table where a value should stand — the answer is nothing and the document is left as it was. Otherwise the value is replaced, the answer is whether it actually moved, and the line that held it is the only one written fresh when the document is written back.

    Doc::to_repr

    Doc::value

    fn Doc::value(self : Doc) -> Value

    The tree the document denotes — what decode answers for the same bytes.

    Edition

    pub(all) enum Edition {
    V04
    V05
    V100
    V110
    } derive(Eq,
    Debug
    )

    Which edition of the format a document is read under.

    The editions differ by real grammar: v04 has no dotted keys, no hex/octal/binary integers and no inf/nan; v05 adds those plus the three local date-time kinds; v100 is the current strict reading, and v110 relaxes inline tables to span lines with a trailing comma, adds the \x and \e escapes and lets a time drop its seconds.

    Edition::equal

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

    Edition::not_equal

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

    Edition::to_repr

    Flavor

    pub(all) struct Flavor {
    depth : Int
    edition : Edition
    } derive(Eq,
    Debug
    )

    How a document is read.

    The preset is strict: TOML 1.0.0 and a nesting limit of 500. Build another with Flavor::new, or update one in place with { ..strict, depth: 64 }. The version-specific presets live in the v04, v05 and v110 packages.

    There is no knob for a repeated key. TOML 1.0.0 §1 makes redefinition an error, not a policy, so making it configurable would be offering to read a document the format says is not one.

    Flavor::equal

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

    Flavor::new

    fn Flavor::new(depth? : Int, edition? : Edition) -> Flavor

    A flavor by name, every knob with the preset's value.

    Flavor::not_equal

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

    Flavor::to_repr

    Item

    pub(all) struct Item {
    kind : ItemKind
    path : Array[String]
    start : Int
    vstart : Int
    vend : Int
    end : Int
    changed : Bool
    } derive(
    Debug
    )

    One thing a document holds, in the order it was read: a table header or a key-value pair.

    The four offsets say where the line and, for a pair, its value sat in the source. The document tiles exactly as the run of its items and the text between them, so writing it back over the source is the original byte for byte unless a pair's value has moved, in which case only that value is written fresh and the key, the separator and any trailing comment are kept as they were.

    Item::to_repr

    Item::value_of

    fn Item::value_of(self : Item, root : Value) -> Value

    The value an item of a document holds, walking the tree to its path.

    ItemKind

    pub(all) enum ItemKind {
    Header
    Pair
    } derive(
    Debug
    )

    The kind of thing an item of a document is.

    ItemKind::to_repr

    Real

    pub(all) struct Real {
    value : Double
    repr : String?
    } derive(
    Debug
    )

    A float, and the text it was written as.

    TOML spells one number many ways — 1e06, 1000000.0, 1_000_000.0 — and a document read and written again should not turn one spelling into another. The text is kept whenever the source had one and it is also a JSON spelling, since that is what a tree of Json can carry; a float built in code has none and is written from its value.
    impl Eq for Real

    Real::equal

    fn Real::equal(self : Real, other : Real) -> Bool

    Real::new

    fn Real::new(value : Double) -> Real

    A float from its value alone.

    Real::not_equal

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

    Real::to_repr

    Value

    pub(all) enum Value {
    String(String)
    Integer(Int64)
    Float(Real)
    Boolean(Bool)
    Datetime(
    Moment
    )
    Array(Array[Value])
    Table(Map[String, Value])
    } derive(
    Debug
    )

    A TOML value, kind for kind.

    A Json tree has no date-time and only one number, so loads reads a timestamp into text and a 64-bit integer into a double. decode reads the same document and keeps what the format says it is, which is what writing it back without guessing needs.
    impl Eq for Value
    impl ToJson for Value

    Value::array

    fn Value::array(items : Array[Value]) -> Value

    An array value.

    Value::boolean

    fn Value::boolean(flag : Bool) -> Value

    A boolean value.

    Value::equal

    fn Value::equal(self : Value, other : Value) -> Bool

    Value::integer

    fn Value::integer(whole : Int64) -> Value

    An integer value.

    Value::not_equal

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

    Value::string

    fn Value::string(text : String) -> Value

    A string value.

    Value::table

    fn Value::table(fields : Map[String, Value]) -> Value

    A table value, which is what a document is.

    Value::to_json

    Value::to_repr

    decode

    fn decode(src : StringView, flavor? : Flavor) -> Value raise Refused

    The document in src, kind for kind.

    TOML defines one document rather than a stream, so there is no plural form of this. This is the reading that keeps every distinction the format makes: a date is a date and not text, and an integer is 64 bits and not a double.

    decode_bytes

    fn decode_bytes(src : BytesView, flavor? : Flavor, bom? : Bool) -> Value raise Refused

    The document in src, decoded as UTF-8.

    TOML 1.0.0 §1 says a file is UTF-8 and does not allow a byte-order mark; bom skips one anyway, because an editor on Windows writes it and refusing the file helps nobody. Pass bom=false to hold the format to its word.

    decode_doc

    fn decode_doc(src : StringView, flavor? : Flavor) -> Doc raise Refused

    The document in src, kept as it was written.

    This is the reading for a file a person edits: change a value in place and write the result back, and the rest of the document — the comments, the blank lines, the order, the spelling of every value but the one that moved — is the source byte for byte. See Doc.

    depth

    let depth : Int

    How deep a document may nest.

    500 is the preset: deep enough for real configuration, shallow enough that a hostile document cannot run the stack out.

    dump

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

    The same, as UTF-8 bytes.

    dumps

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

    Write a Json tree as a TOML document.

    Tables go under their own headers and scalars stay at the level they are on, so the output reads like a configuration file someone wrote rather than one a program emitted. An array of tables is written as [[…]] when every one of its elements is a table, and inline otherwise, which is the only honest choice: TOML has no way to spell an array mixing tables with scalars across headers.

    sort orders keys by name; off, they keep the order the tree has. A document read by this module and written again carries the same values, but not necessarily the same layout — TOML can spell the same tree several ways.

    A Json tree says "string" for what may have been a date-time, so this leans on the spelling: a string that is a date-time is written as one. encode writes a Value and needs no such guess.

    encode

    fn encode(value : Value, sort? : Bool) -> String

    Write a tree of Value as a TOML document.

    Each value keeps the kind it has: a String is quoted even when it reads like a date, an Integer is written as the 64 bits it holds, and a Datetime is written bare, because turning it into a quoted string would change what it is.

    encode_bytes

    fn encode_bytes(value : Value, sort? : Bool) -> Bytes

    The same, as UTF-8 bytes.

    load

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

    The document in src, decoded as UTF-8, as a tree of Json.

    TOML 1.0.0 §1 says a file is UTF-8 and does not allow a byte-order mark; bom skips one anyway, because an editor on Windows writes it and refusing the file helps nobody. Pass bom=false to hold the format to its word.

    loads

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

    The document in src, as a tree of Json.

    A date-time comes out as its RFC 3339 text and an integer as a Json number, because a Json tree has no place to keep either apart; decode is the reading that keeps them.

    strict

    let strict : Flavor

    The preset: TOML 1.0.0 at the preset depth. The v04, v05 and v110 packages each fix a different edition and expose the same entry points.