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
| Package | What 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())
_ => ()
}
loads(src) -> Json raise Refused— one document. A second one isTrailing, because a caller expecting one file's worth of configuration should be told it got a stream.loads_all(src) -> Array[Json]— every document in a---stream, in order. An empty stream is[], not[null]: nothing is not the same as one document containing nothing.load(bytes)/load_all(bytes)— the same over UTF-8, withbom=trueskipping a leading byte-order mark (§5.2 permits one, and an editor will write one).
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.2 | string | 14 | 12 | string | string |
yaml11 — 1.1 | true | 12 | string | 750 | 1000 |
yaml10 — 1.0 | true | 12 | string | 750 | 1000 |
strict | string | string | string | string | string |
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
| Field | Holds |
|---|---|
kind | Null, Bool, Number, Str, Sequence, Mapping, or Alias(name) |
text | The scalar's text exactly as written — 014, yes, ~ |
style | Plain, Single, Double, Literal(n), Folded(n), where n is the chomping indicator |
flow | Whether the collection was written {…}/[…] |
anchor, tag | The &name and the resolved tag, or None |
before, after | The comments above the node, and the one after it on its own line — each without its # |
explicit, closed | Whether 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 ---
Style | Default | Why |
|---|---|---|
indent | 2 | Two columns per level: what almost every configuration file in the wild uses, and what fits a diff |
width | 80 | Where a collection stops fitting on one line and Auto breaks it |
layout | Block | Block reads better in a diff. Flow is JSON's shape, Auto decides per collection by width |
quotes | Minimal | Quote only what has to be quoted, which is what a person writing YAML does |
anchors | true | A collection that appears twice is written once and referenced |
sort | false | A 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))
Flavor | Default | Why |
|---|---|---|
depth | 500 | Deeper than any configuration file nests, shallow enough that the reader's own recursion cannot be driven off the stack |
duplicates | Last | A 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 |
aliases | true | An alias is part of the language; false refuses one outright, which is what reading untrusted input may want |
budget | 10 000 000 | Nodes 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:
| Arm | When |
|---|---|
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.