std/yaml

Yaml reads and writes any Encode/Decode type as YAML 1.2 or 1.1, with the target type resolving every scalar, and YamlNode is the tree of a document with its anchors, tags, styles and comments.

The generated reference of std/yaml has every public construct with its signature, its doc comment and its examples.

std/yaml is YAML as a format of std/encoding, written in TorbScript: all of YAML 1.2, and YAML 1.1 for a document that says %YAML 1.1. Yaml reads a document into any Decode type and writes any Encode value, and YamlNode is the tree of a document as it was written - anchors, aliases, tags, scalar and collection styles and comments included - so a tool can change one value of a configuration file and write the rest back as it was. The reader passes all 402 cases of the YAML test suite, which are its tests. The design is TEXT-FORMATS.md section 1.

Import#

use Yaml, YamlVersion, YamlError, YamlSchema, YamlTag from "std/yaml"
use YamlNode, YamlContent, YamlEntry, YamlDocument, YamlComments, ScalarStyle, CollectionStyle from "std/yaml"
use Yaml from "std/yaml"

type Service {
  name: String
  replicas: Int = 1
  ports: List<Int> = []
  enabled: Bool = true
}

const yaml = Yaml()
match yaml.decode<Service>("name: web\nports: [80, 443]\nenabled: no\n") {
  Ok(service) => print yaml.encode(service)
  Fail(problem) => print "Rejected: {problem}"
}

enabled: no is refused there: no is a text, and a Bool field wants true or false. In a String field the same scalar is the text "no".

Declarations#

Yaml#

type Yaml {
  naming: Naming = Naming.Unchanged
  strict: Bool = false
  schema: YamlSchema? = None
  version: YamlVersion = YamlVersion.Yaml12
  tags: List<YamlTag> = []
  aliasLimit: Int = 100000
  mergeKeys: Bool = true
  flagsAsNames: Bool = false
  fn encode<Value: Encode>(value: Value): String
  fn decode<Value: Decode>(text: String): Result<Value, YamlError>
  fn decodeDocuments<Value: Decode>(text: String): Result<List<Value>, YamlError>
  fn decodeDocument<Value: Decode>(document: YamlDocument): Result<Value, YamlError>
  fn parse(text: String): Result<YamlDocument, YamlError>
  fn parseAll(text: String): Result<List<YamlDocument>, YamlError>
  fn resolved(document: YamlDocument): Result<EncodedValue, YamlError>
  fn value<Value: Encode>(value: Value): YamlNode
  fn write(document: YamlDocument): String
  fn writeAll(documents: List<YamlDocument>): String
}
extend Yaml with Format<YamlError>

A Yaml is the format with its options. naming spells every field name and strict makes a field of the document that no type asked for an error, as they do for std/json. version is the version of a document without a %YAML directive; a document that has one is read by it. tags are the local tags a typed decode accepts and a typed encode writes. aliasLimit is how many nodes the aliases of one document may add - the "billion laughs", nine levels of ten aliases each, fails there instead of filling the memory. mergeKeys applies the merge key <<, which CI definitions use; it is applied in every document, because most of them say no version. flagsAsNames writes a Flags as the sequence of its case names and reads it back from one, where it is a number (its mask) by default, as it does for std/json.

The target type resolves a scalar, not a schema. decode<Value> reads a plain scalar as what the field asks for: no, on and 0777 are texts in a String field, no is an error in a Bool field, and 0777 is 777 in an Int field. A document that says %YAML 1.1 (or a Yaml(version: .Yaml11)) reads by 1.1's rules: yes, no, on and off are truth values there, 0777 is octal and 1_000 a thousand. A quoted scalar is always a text, and an explicit tag of the core schema is honoured (!!int "5" is a number). An empty value is None for an optional field, an empty list or map for a collection, and a record of defaults for a record.

A record is a mapping of its fields, a record that announces no field is its one value, a case without fields is its name and a case with fields a mapping of one entry, its name (Circle: {radius: 2}); a YamlTag with a caseName lets a local tag choose the case instead (!circle {radius: 2}). A !!binary scalar reads into a Bytes.

decode reads a text of one document - several are an error that names decodeDocuments, and a text without one reads as null. A failure is a YamlError with the line and the field chain:

line 4: ports[1]: a whole number is needed, not `http`

decodeDocument reads a document that was parsed already, which is what a front matter reader does.

encode writes YAML 1.2, one field per line, indented by two spaces. A string is quoted where a reader would take it for something else - by YAML 1.2's core schema or by YAML 1.1's types: no, on, y, 0777, 1_000, 12:30, 2024-01-01, ~, << and every number are written in double quotes, so what encode writes reads the same under both versions. That includes keys: a field y is written "y":. A text of several lines is a literal block (|).

parse and parseAll read the tree; write and writeAll write it back with everything it holds. resolved reads a document without a type into an EncodedValue, its plain scalars resolved by schema or, where that is None, by the document's version. value is the tree an Encode value is written as.

extend Yaml with Format<YamlError> gives Yaml.items<Item>(), a Stage that frames a stream of bytes into its documents - at the lines that start with --- or ..., which YAML allows nowhere else - and decodes each one, and Yaml.encoded<Item>(), which writes each item as a document that starts with ---.

YamlNode#

type YamlNode {
  content: YamlContent
  tag: String? = None
  anchor: String? = None
  comments: YamlComments = YamlComments()
  line: Int = 0
  column: Int = 0
  static fn scalar(text: String, style: ScalarStyle = ScalarStyle.Plain): Self
  static fn sequence(items: List<Self>, style: CollectionStyle = CollectionStyle.Block): Self
  static fn mapping(entries: List<YamlEntry>, style: CollectionStyle = CollectionStyle.Block): Self
  static fn alias(name: String): Self
  static fn empty(): Self
  fn text(): String?
  fn items(): List<Self>
  fn entries(): List<YamlEntry>
  fn isScalar(): Bool
  fn at(key: String): Self?
  fn updated(key: String, value: Self): Self
}
type YamlContent {
  case Scalar(text: String, style: ScalarStyle)
  case Sequence(items: List<YamlNode>, style: CollectionStyle)
  case Mapping(entries: List<YamlEntry>, style: CollectionStyle)
  case Alias(name: String)
}
type YamlEntry {
  key: YamlNode
  value: YamlNode
}
type YamlComments {
  leading: List<String> = []
  trailing: String? = None
  fn isEmpty(): Bool
}

One node as it was written. A scalar keeps its text - after quotes, escapes and folding - and its style, and is not resolved: yes is the text "yes" until a schema or a type says what it is. tag is resolved to its full name (tag:yaml.org,2002:str for !!str, !point for a local tag, ! for the non-specific one), and line and column count from 1. A comment on a line of its own belongs to the node below it - the key of an entry, the item of a sequence

  • and a comment at the end of a line to the last node that ends there: name: web # the name is the value's.

updated(key, value) answers the mapping with one value replaced and everything else - the other entries, their order, the comments - as it was, which is how a tool edits a file.

ScalarStyle is Plain, SingleQuoted, DoubleQuoted, Literal or Folded; CollectionStyle is Block or Flow. The writer keeps a style wherever the text allows it, and quotes where it does not.

YamlDocument#

type YamlDocument {
  root: YamlNode
  version: String? = None
  tagDirectives: List<YamlTagDirective> = []
  explicitStart: Bool = false
  explicitEnd: Bool = false
  leadingComments: List<String> = []
  endComments: List<String> = []
  fn isVersion11(): Bool
}
type YamlTagDirective {
  handle: String
  prefix: String
}

One document of a stream: its root, its %YAML and %TAG directives, whether it was opened with --- and closed with ..., and the comments before and after its root.

YamlSchema and YamlVersion#

type YamlSchema {
  case Failsafe
  case Json
  case Core
  case Yaml11
}
type YamlVersion {
  case Yaml12
  case Yaml11
}

The schemas of the specification, for resolved: Failsafe reads every scalar as a text, Json reads null, true, false and JSON's numbers and refuses every other plain scalar, Core is YAML 1.2's (null in four spellings, truth values in three, decimal, 0o and 0x numbers, .inf and .nan), and Yaml11 adds 1.1's truth values, 0b and 0 prefixes, _ between digits and base 60. A timestamp stays a text in every schema. !!set resolves to a sequence of its keys, !!omap and !!pairs to a mapping in their order, and !!binary to bytes.

YamlTag#

type YamlTag {
  tag: String
  typeName: String
  caseName: String? = None
}

A local tag and the type it stands for, by the qualified name the type announces to a format ("app/geometry/Point"). A program has no reflection, so !point means a type only through this mapping: a typed decode accepts a node tagged !point where that type is read, and refuses a tag nobody named with an error that names it; a typed encode writes the tag. With caseName, the tag chooses a case of a type with cases. resolved reads a mapping with a known local tag as a Record of that type name.

YamlError#

type YamlError with Show, Error {
  static fn syntax(line: Int, column: Int, message: String): Self
  static fn content(line: Int, message: String): Self
  static fn decodeFailed(cause: DecodeError, line: Int = 0): Self
  static fn invalidText(cause: Utf8Error): Self
  fn isSyntaxError(): Bool
  fn line(): Int
  fn message(): String
  fn show(): String
  fn cause(): Error?
}
extend YamlError with From<Utf8Error>

What went wrong: text that is not YAML (syntax, with its line and column), a document whose content cannot be resolved - an unknown tag, a merge key on something else than a mapping, aliases beyond the limit (content) - a document that does not fit the type (decodeFailed, with the line of the node it failed on), or bytes that are not UTF-8.

YamlDecoder and YamlEncoder are the Decoder and Encoder behind decode and encode, exported for a format that builds on them.

Esc