moonyamlmoonbitstack

YAML · MoonBit · Apache-2.0

YAML, read as a value and as a document.

Five versions of the language — 1.2.2, 1.1, 1.0, the strict dialect, and one that reads the version a file asks for — from a single scanner. A version is a resolver here, not a fork. And every one of them can be written back, comments and all.

tour.mbt
// one document, read four ways
let config = @moonyaml.loads(
  #|server:
  #|  host: 0.0.0.0
  #|  port: 12000
  #|limits: &shared { files: 1000 }
  #|upload: *shared
)

@moonyaml.dumps(config)     // back to YAML
@moonyaml.loads_all(text)   // every document
@moonyaml.parse_all(text)   // comments kept

01 · why

YAML is not a JSON dialect.

It has its own specification, its own versions that disagree about what a word means, and constructs — anchors, tags, block scalars, document streams — with no JSON counterpart. It reads into a Json tree because that is what a caller wants, not because it is the same language. Pretending otherwise is how a configuration file comes to mean something other than what it looks like.

one scanner

Versions are resolvers

The grammar is the same in every version; what differs is what a scalar means. So the scanner is shared, the resolver is a record, and a version is a value you can pass to a function.

two shapes

Value or document

loads gives a Json. parse gives a Node that remembers the comment above a key, the flow style of a short list, the anchor, the tag and the quoting — so emit can write the file back.

refusal

Ambiguity is an error

Everything that cannot be resolved to one reading is refused with a line and a column, rather than guessed at. A tag that is unknown is not dropped: a document that asked for a type and did not get it has not been read.

budget

Alias expansion, bounded

An alias may name a node that holds aliases, so a dozen lines can expand to billions of nodes. Here the expansion is counted and capped, and going over is an error with the count in it.

02 · the same file, five readings

Where the versions part company.

input.yaml
enabled: yes
mask: 014
starts: 12:30
what each version makes of it
1.2.2  {"enabled":"yes", "mask":14, "starts":"12:30"}

1.1    {"enabled":true, "mask":12, "starts":750}

1.0    {"enabled":true, "mask":12, "starts":750}

strict {"enabled":"yes", "mask":"014", "starts":"12:30"}

Four readings of one file, and not one of them is a bug. yes is a boolean in 1.1 and a string in 1.2; 014 is octal twelve in 1.1 and decimal fourteen in 1.2; 12:30 is a sexagesimal only in 1.1. Which of those you meant is a property of the document, not of the reader — so it is a package you choose, not a flag that guesses.

03 · the document

Write the file back the way it was written.

before
# the deployment
name: web
defaults: &defaults
  replicas: 3
front:
  <<: *defaults
  image: nginx:1.27-alpine # the only change
ports: [80, 443]
motd: |
  one
  two
after parse → emit
# the deployment
name: web
defaults: &defaults
  replicas: 3
front:
  <<: *defaults
  image: nginx:1.27-alpine # the only change
ports: [80, 443]
motd: |
  one
  two

The comment above the document, the comment beside the image line, the anchor on defaults, the alias that stands for its value, the flow style of ports, the block scalar's chomping indicator, the quoting of a string that had to be quoted, the --- a document opened with — all of it is in the tree, and all of it comes back. to_json is there for the caller who wants the value instead, with the merge key folded in and the alias resolved.

A mapping in that tree is a list of pairs rather than a map: order is part of a document, a key is a node rather than a string, and a file may have a sequence as a key or the same key twice. None of those fits a Json, and all of them fit a document.

04 · the parse

What it saw, before what it means.

A value loses the shape of the document: a quoted scalar and a plain one are the same string, a flow collection and a block one are the same list, and a repeated key is gone. Node::events reports the parse instead — one line per event, the notation the YAML Test Suite writes a parse tree in.

That is what a caller compares with when two documents should be the same document: an edit that was supposed to change one value either changed one value or it did not, and the events say which.

events in, events out
+STR
 +DOC
  +MAP
   =VAL :name
   =VAL :web
  -MAP
 -DOC
-STR

05 · refusing

What it will not guess at.

A reader that is permissive where the specification is ambiguous is a reader that will one day silently read something else. This one raises Refused — a suberror with a position, not a string — and the message says which rule it was.

The check is a property of the parse, not a lint afterwards: nothing is read into a tree that has already been guessed at.

refusals
a: [1, 2      1:4  ends mid-document
a: 1
a: 2          2:1  the key `a` is written twice
a: &x 1
b: *y         2:4  no anchor named `y`

06 · next

Start where you are.

guide

The guide

Reading, writing, the five versions, the document tree, every knob and why its default is what it is.

reference

The API

Every public type, value and function, with the signature the compiler produces and the comment that explains it.

examples

Three programs

A tour of everything it reads, one document read five ways, and a round trip that keeps its comments — with their real output.