moonyamlmoonbitstack

Install

One module, five packages. The root package is YAML 1.2.2 and the writer; the others are a version each.

moon add moonbitstack/moonyaml
PackageWhat it is
moonyaml 1.2.2's core schema, the writer, the document model, the typed codec
moonyaml/yaml11 yes/no booleans, 014 octal, sexagesimals, 1_000, the << merge key
moonyaml/yaml10 YAML 1.0's types, which 1.1 inherited but did not always keep
moonyaml/strict The strict dialect: block structure only, every scalar a string
moonyaml/any The version the document asks for with %YAML, 1.2.2 when it asks for nothing

They are packages rather than a flag because a version is a promise about meaning. Each carries the same loads/loads_all/load/load_all/dumps/dumps_all, so one can be held in a variable and passed to a function.

Reading a value

loads returns a Json. Mappings become objects, sequences arrays, and a scalar becomes whatever the schema says its text means.

let config = @moonyaml.loads(
  #|server:
  #|  host: 0.0.0.0
  #|  port: 12000
  #|tags: [http, native]
)

match config {
  Json::Object(m) => println(m.get("server").unwrap().stringify())
  _ => ()
}

What a plain scalar means is the core schema of §10.2: null/~ is null, true/false are booleans, 123, 0o17, 0x1f, 1e3, .inf and .nan are numbers, and everything else is a string. yes, on, 014 and 12:30 are strings here, because that is what 1.2 says they are. Quoting something makes it a string whatever it looks like, and a tag overrides the schema.

@moonyaml.loads("a: yes\nb: 014\nc: 12:30\n").stringify()
// {"a":"yes","b":14,"c":"12:30"}

@moonyaml.loads("a: [1, 2, 3]\nb: {x: 1}\n").stringify()
// {"a":[1,2,3],"b":{"x":1}}

Streams and files

A file may hold several documents, each introduced by --- and optionally closed by .... loads_all reads all of them; load takes the bytes and decodes UTF-8 first, with the error a byte-level one when it is not.

let docs = @moonyaml.loads_all(
  #|---
  #|first: 1
  #|---
  #|second: 2
)
println(docs.length())  // 2

let from_disk = @moonyaml.load(bytes)   // one document
let all = @moonyaml.load_all(bytes)      // every document

A BOM is not content. load skips one by default, because an editor on Windows writes one and a file that begins with three bytes nobody typed is not a file that should fail to load. Pass bom=false to refuse it instead.

The five versions

The grammar does not differ between versions; what a scalar means does. So the scanner is shared and the version is a resolver, which is why a version is a value you can pass rather than a branch you have to write.

Version yes 014 0o14 12:30 1_000
moonyaml — 1.2.2string1412stringstring
yaml11 — 1.1true12string7501000
yaml10 — 1.0true12string7501000
strictstringstringstringstringstring

Each is a value of the same shape, so a caller can take one as a parameter:

let config = @yaml11.loads(src)      // yes is a boolean here
let legacy = @yaml10.loads(src)      // and y is one too
let plain  = @strict.loads(src)      // every scalar is a string
let asked  = @any.loads(src)         // %YAML 1.1 at the top decides

any is the only package that guesses, and it guesses once, from the document, at the top. A %YAML 1.1 directive makes it read as 1.1; %YAML 1.2 or no directive at all makes it 1.2.2. A directive naming a version it does not have is refused rather than approximated. declared_version(src) is that first directive, for a caller who would rather ask than delegate.

Picking one. Read your own files with moonyaml. Read files you did not write with any. Reach for yaml11 when a file says yes: and means a boolean — a playbook, an older compose file, anything that grew up in the 2000s — and say so in the module that reads it.

The strict dialect

strict is not a version of YAML with different scalars: it is a smaller language. Block structure only, every scalar a string, no flow collections, no anchors or aliases, no tags, and a key written twice is a fault rather than a decision.

@strict.loads("port: 8080\nenabled: yes\n").stringify()
// {"port":"8080","enabled":"yes"}

@strict.loads("list: [1, 2]\n")
// refused — 1:7: flow style is not allowed here

@strict.loads("a: &x 1\nb: *x\n")
// refused — 1:4: an anchor is not allowed here

What it buys is a file that cannot say anything surprising: a value that looks like a number is the text of that number, and a reader that wants an integer has to say so. strict.schema and strict.flavor are the two records it is built from, for a caller who wants to start from them.

The document tree

loads answers what a file means. parse answers what it says. The difference is a Node, which keeps the comment above a key, the comment beside a value, the quoting, the flow style, the anchor, the tag, and whether the document was opened by a --- or closed by a ... — so the file can be written back.

let doc = @moonyaml.parse(src)
println(@moonyaml.emit(doc))        // the same document
println(doc.to_json().stringify())  // what it means
FieldHolds
kindNull, Bool, Number, Str, Sequence, Mapping, or Alias(name)
textThe scalar's text exactly as written — 014, yes, ~
stylePlain, Single, Double, Literal(n), Folded(n), where n is the chomping indicator
flowWhether the collection was written {…}/[…]
anchor, tagThe &name and the resolved tag, or None
before, afterThe comments above the node, and the one after it on its own line — each without its #
explicit, closedWhether the document this node heads was opened by a --- or closed by a ...

A mapping is an Array[(Node, Node)], not a map: order is part of a document, keys are nodes rather than strings, and a file can legitimately have a sequence as a key or the same key written twice. An alias stays an alias, so emit writes *shared rather than the several lines it stands for. to_json is there for the caller who wants the value: it resolves the aliases and folds << in.

Build one by hand, or from a value:

let doc = @moonyaml.Node::map([
  (@moonyaml.Node::scalar("name"), @moonyaml.Node::scalar("web")),
  (
    @moonyaml.Node::scalar("ports"),
    @moonyaml.Node::seq([
      @moonyaml.Node::scalar("80"), @moonyaml.Node::scalar("443"),
    ]).commented(before=["what it listens on"], after=""),
  ),
])

let built = @moonyaml.Node::of_json(@moonyaml.loads(src))

Walking a tree is a match on kind. An edit is the tree you put back — the same node with another value, the same pair with another key — and nothing else in the document moves:

let renamed = match doc.kind {
  Mapping(pairs) =>
    @moonyaml.Node::map(
      pairs.map(fn(pair) {
        if pair.0.text == "name" {
          (pair.0, @moonyaml.Node::scalar("api"))
        } else {
          pair
        }
      }),
    )
  _ => doc
}

Writing a document back

emit writes the tree it is given: the style each scalar had, the flow flag of each collection, the chomping indicator of each block scalar, the comments, the anchors, the tags, and the markers the document carried.

@moonyaml.emit(doc)               // indent 2, from column 0
@moonyaml.emit(doc, indent=4)     // four spaces per level
@moonyaml.emit(doc, from=2)       // embedded, starting at column 2

Two things it settles rather than keeps. A scalar whose text holds a break is written over several lines, with each break spelled as the blank line that folds back into it — there is no other way to spell a break in a plain or single-quoted scalar. And a block scalar whose first line begins with a space gets an explicit indentation indicator, because without one the reader would take that space for the indentation and the value would come back missing a character.

The claim worth checking is not that the text is byte-identical — emit decides its own line breaks — but that reading it back gives the same document. That is what Node::same and the events below are for.

Comparing documents

Node::events writes the parse instead of the value: one line per event, in the notation the YAML Test Suite records a parse tree in. Two documents are the same document when their events are the same lines.

let doc = @moonyaml.parse("name: web\nports: [80, 443]\n")
print(doc.events())
+DOC
 +MAP
  =VAL :name
  =VAL :web
  =VAL :ports
  +SEQ []
   =VAL :80
   =VAL :443
  -SEQ
 -MAP
-DOC

A scalar's line starts with the style it was written in — : plain, ' single, " double, | literal, > folded — and a flow collection carries [] or {}. An anchor is &name and a tag <tag>, before the value. events_all(docs) wraps a whole stream in +STR and -STR.

Typed values

A YAML file usually has a shape someone already wrote down. Through @json.FromJson and ToJson — the traits a Json value already uses — a document becomes that record and back:

let server : Server = @moonyaml.loads_as(src)
let yaml = @moonyaml.dumps_of(server)
let again = @moonyaml.round_trip[Server](src, adjust=fn(s) {
  s.port = 8081
})

loads_as and load_as raise Malformed, which has two arms: Read(Refused) when the text is not YAML, and Shape(JsonDecodeError) when it is YAML of the wrong shape. Keeping them apart is the difference between "this file is broken" and "this file is not a Server", which is the difference between a bug and a misconfiguration.

round_trip is for checking a decode, not for converting: it decodes, lets adjust change the value in place, writes it back and returns the text — so a decode that loses a field shows up as a missing line in the output rather than as a surprise in production.

Writing a value

dumps writes block style at indent 2, quoting only what has to be quoted. Every choice it makes is a field of Style.

@moonyaml.dumps(value)                       // block, indent 2, width 80
@moonyaml.dumps(value, style=@moonyaml.flow)  // JSON's own shape
@moonyaml.dumps(value, indent=4, sort=true)   // flat parameters win
@moonyaml.dumps_all([a, b])                  // each opened with ---
StyleDefaultWhy
indent2Two columns per level: what almost every configuration file in the wild uses, and what fits a diff
width80Where a collection stops fitting on one line and Auto breaks it
layoutBlockBlock reads better in a diff. Flow is JSON's shape, Auto decides per collection by width
quotesMinimalQuote only what has to be quoted, which is what a person writing YAML does
anchorstrueA collection that appears twice is written once and referenced
sortfalseA document keeps the order it was written in. Turn it on for a stable file

A value keeps the order it has unless sort says otherwise. A string that could be read as a number, a boolean, a null or a tag is quoted; a string with a newline in it becomes a literal block with the chomping indicator that reproduces it exactly.

dumps also takes rules, so a 1.1 Json tree can be written with 1.1's spellings — yes for a boolean, 014 for twelve — rather than 1.2's.

Changing a default

Two records, each with presets, each field overridable, and every knob also reachable as a named argument on the call. The order is preset, then record, then flat argument: a flat argument wins over a field of the record, which wins over the preset's value. There is no other way to change a default, and no default is hidden.

@moonyaml.loads(src, flavor=@moonyaml.Flavor::new(duplicates=Reject))
@moonyaml.loads(src, flavor={ ..@moonyaml.strict, budget: 1000 })
@moonyaml.dumps(value, style=@moonyaml.Style::new(quotes=Single))
FlavorDefaultWhy
depth500Deeper than any configuration file nests, shallow enough that the reader's own recursion cannot be driven off the stack
duplicatesLastA mapping names each key once (§3.2.1.1); where one is written twice, the last is what the document ends with. First and Reject exist because a repeated key is usually a mistake
aliasestrueAn alias is part of the language; false refuses one outright, which is what reading untrusted input may want
budget10 000 000Nodes one document's aliases may expand to. A dozen lines of aliases can name billions of nodes, so the expansion is counted and capped

The budget is what an alias costs a reader that does not count: an alias may name a node that itself holds aliases, so ten lines can expand to millions of nodes. Here the expansion is counted, and going over it is Exceeded with the count and the limit in it, not an out-of-memory.

@moonyaml.loads(
  #|a: &a [x, x]
  #|b: &b [*a, *a]
  #|c: &c [*b, *b]
  #|d: [*c, *c]
  ,
  flavor=@moonyaml.Flavor::new(budget=8),
)
// refused — 2:8: expands to 9 nodes, over 8

Refusals

Refused is a suberror with twelve arms, each carrying an At — byte offset, line and column — so a caller can match on the kind of fault rather than read a string:

ArmWhen
Unexpected(at, c)A character that cannot stand where it was written
Truncated(at)The text ends inside a document
BadEscape(at)A double-quoted escape that is not one of §5.7's
BadIndent(at)A line indented to a column that lines up with nothing
BadTag(at, tag)A tag nobody has, or a scalar tagged as a collection
BadAnchor(at, name)An alias naming an anchor that was not defined
Repeated(at, key)The same key twice, when duplicates is Reject
Trailing(at)A second document, from loads
TooDeep(at)Past depth
Exceeded(at, limit, got)Aliases expanding past budget
BadUtf8(at)Not UTF-8, from the byte entry points
Disallowed(at, what)A construct the version does not have — a flow collection under the strict dialect, a tag, a key that is a collection

Show prints them as a position and a sentence:

a: [1, 2      1:4: ends mid-document
a: *nope      1:4: no anchor named `nope`
a: 1
a: 2          2:1: the key `a` is written twice

A tag from a vocabulary this reader does not have is not one of these. §10.4 permits a processor to resolve such a node as if it carried no tag, and that is what happens, so !!python/object 1 reads as the number 1 rather than failing the file. parse keeps the tag on the node, so it survives a write.

Where to go next

Every public type, value and function is on the API page, generated from the packages' interfaces and their comments. The three programs on the examples page cover the same ground with their real output.