moonyaml

    moonyaml — YAML for MoonBit. Reads and writes YAML 1.2.2 into the same `Json` tree every other reader in this family produces: block and flow collections, the five block scalar forms, anchors and aliases, tags, and multi-document streams. What it does not understand it refuses rather than guesses.

    yaml
    config
    parser
    moonbit
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    9 days ago
    Downloads
    88

    #moonyaml

    YAML 1.2.2, read and written as the Json tree every other reader in this family produces. JSON is a subset of YAML, so a document that is JSON loads as itself.

    let config = @moonyaml.loads(
    #|server:
    #| host: 0.0.0.0
    #| port: 12000
    #| motd: |
    #| welcome
    #| to the party
    #|limits: &shared { files: 1000, bytes: 1048576 }
    #|upload: *shared
    )

    @moonyaml.dumps(config) // back to YAML, block style
    @moonyaml.loads_all(stream) // every document in a `---` stream

    #What it reads

    CollectionsBlock mappings and sequences, flow {} and [], nested in either direction, a sequence in its parent key's own column
    ScalarsPlain over several lines, single- and double-quoted with the escapes of §5.7, literal \| and folded > with chomping and explicit indentation
    StructureAnchors and aliases, tags including %TAG handles and verbatim !<…>, %YAML, --- and ... document markers
    ResolutionThe core schema of §10.2: null/~, booleans, decimal, 0o and 0x integers, floats with exponents, .inf and .nan

    yes and no are strings here, as YAML 1.2 says they are. They are booleans in YAML 1.1, which is a different version and belongs in a v11 package rather than in a flag on this one.

    #What it refuses

    Everything it cannot resolve to one reading. YAML's own answer to ambiguity is to be permissive, which is how a configuration file comes to mean something other than what it looks like; this reader raises Refused with a line and column instead.

    An unknown tag is refused rather than dropped, because a document that asked for a type and did not get it has not been read. A mapping key that is itself a collection is refused, because the tree this produces has string keys and there is no honest way to spell a sequence as one.

    #Configuration

    @moonyaml.loads(src, flavor=@moonyaml.Flavor::new(duplicates=Reject))
    @moonyaml.loads(src, flavor={ ..@moonyaml.strict, budget: 1000 })

    KnobPresetWhy
    depth500Between Jackson's 1000 and serde_json's 128, the same as this family's JSON
    duplicatesLastWhat every loader in reach does; First and Reject are there because a key written twice is usually a mistake
    aliasestrueAn alias is part of the language; false refuses one outright
    budget10 000 000An alias can name a node holding aliases, so a dozen lines can expand to billions — the "billion laughs", which libyaml and PyYAML's safe_load both still perform faithfully. There is no common value to follow, so this takes the safe side

    The writer takes indent (2, what Kubernetes manifests and Compose files use) and sort (off, so a document keeps the order it was written in).

    dumps_all opens each document with its own ---. The two conventions are both in use — PyYAML and Go's yaml.v3 write the marker only between documents, kubectl writes it before each — and this takes the one that stays correct under concatenation. The reader accepts either.

    #What is not here

    YAML 1.1 (yes/no booleans, sexagesimals, the << merge key) and StrictYAML, which are planned as v11 and strict packages beside this one. A rich document model that keeps anchors, tags and non-string keys as themselves rather than resolving them into a Json tree; the tracking list lives with the project.

    #Why this is a library

    YAML is not a JSON dialect. It has its own specification, its own versions that disagree with each other, and constructs — anchors, tags, block scalars, document streams — that have no JSON counterpart. It reads into the same tree because that is what a caller wants, not because it is the same language.

    #Install

    moon add moonbitstack/moonyaml

    #Licence

    Apache-2.0. See LICENSE.

    Refused

    pub(all) suberror Refused {
    Unexpected(At, Char)
    Truncated(At)
    BadEscape(At)
    BadIndent(At)
    BadTag(At, String)
    BadAnchor(At, String)
    Repeated(At, String)
    Trailing(At)
    TooDeep(At)
    Exceeded(at~ : At, limit~ : Int, got~ : Int)
    BadUtf8(At)
    } derive(Eq,
    Debug
    )

    Why a document was refused.

    YAML's own answer to ambiguity is to be permissive, which is how a configuration file comes to mean something other than what it looks like. This reader refuses instead: anything it cannot resolve to one reading is an error with a position, never a guess.
    impl Show for Refused

    Refused::at

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

    Where the refusal happened.

    Every variant carries a position, so a caller that wants to report the line and column does not have to match all eleven 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.

    Every refusal carries one, because a configuration file that will not load is read by a person who needs to know which line to look at.
    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

    Duplicates

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

    What to do when a mapping names the same key twice.

    YAML 1.2.2 §3.2.1.1 says the keys of a mapping are unique and leaves the handling of a duplicate to the processor. Every loader in reach takes the last one, which is what Last does; First and Reject are here because a configuration file with a key written twice is usually a mistake someone would rather be told about.

    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 {
    depth : Int
    duplicates : Duplicates
    aliases : Bool
    budget : Int
    } derive(Eq,
    Debug
    )

    How a document is read: the limits, and what to do with a duplicate key.

    The preset is [strict]. Build another with [Flavor::new], or update one in place with { ..strict, depth: 64 }.

    Flavor::equal

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

    Flavor::new

    fn Flavor::new(depth? : Int, duplicates? : Duplicates, aliases? : Bool, budget? : Int) -> 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

    budget

    let budget : Int

    How many nodes the aliases in one document may expand to.

    An alias can name a node that itself contains aliases, so a dozen lines can expand to billions — the "billion laughs" of YAML, which libyaml and PyYAML's safe_load both still perform faithfully. There is no common value to follow here, so this takes the safe side: enough for any real document, far short of exhausting memory. Flavor::new(budget=...) raises it, and aliases=false refuses them outright.

    depth

    let depth : Int

    How deep a document may nest.

    500 is what this family uses for JSON, between Jackson's 1000 and serde_json's 128, and a YAML document that nests deeper than a JSON one would is not a configuration file.

    dump

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

    The same, as UTF-8 bytes — the s on [dumps] is the one Python puts on the form that answers a string.

    dump_all

    fn dump_all(values : ArrayView[Json], indent? : Int, sort? : Bool) -> Bytes

    The same, as UTF-8 bytes.

    dumps

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

    Write a Json tree as a YAML document.

    Block style, which is what a configuration file is written in and what a person reads. A string is quoted only when plain would read back as something else — a number, a boolean, a null, or not at all — so the output looks like a file someone wrote rather than one a program emitted.

    This is the reason to have a writer at all: a generator that builds YAML by joining strings gets the escaping wrong the first time a value contains a colon, and gets the indentation wrong the first time a value is a list.

    dumps_all

    fn dumps_all(values : ArrayView[Json], indent? : Int, sort? : Bool) -> String

    A stream of documents, each opened by its own ---.

    The separator goes before each document rather than between them. The two conventions are both in use — PyYAML and Go's yaml.v3 write it only between, kubectl writes it before each — and this takes the one that stays correct under concatenation: two streams joined end to end still read as their documents, and appending one is appending lines. The reader accepts either.

    indent

    let indent : Int

    How much a nested level is indented.

    Two spaces: what Kubernetes manifests, Compose files and every configuration example in the wild use, and what a reader's eye is trained on.

    load

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

    The one document in src, decoded as UTF-8.

    bom skips a leading byte-order mark, which YAML 1.2.2 §5.2 allows at the start of a stream and which an editor on Windows will happily write.

    load_all

    fn load_all(src : BytesView, flavor? : Flavor, bom? : Bool) -> Array[Json] raise Refused

    Every document in src, decoded as UTF-8.

    loads

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

    The one document in src.

    Raises [Refused::Trailing] if a second document follows, which is what tells a caller expecting one file's worth of configuration that it got a stream.

    loads_all

    fn loads_all(src : StringView, flavor? : Flavor) -> Array[Json] raise Refused

    Every document in src, in order.

    A stream with no documents at all is an empty array, not a null: nothing is not the same as one document containing nothing.

    strict

    let strict : Flavor

    The preset: refuse what is ambiguous, take the last of a repeated key, expand aliases within the budget.