Yaml

:= [
    Null,
    Bool(Bool),
    Int(I64),
    Float(F64),
    Text(Str),
    Sequence(List(Yaml)),
    Mapping(List({ key : Str, value : Yaml })),
]

A practical YAML configuration parser.

This module implements a YAML 1.2 subset aimed at configuration files and Markdown frontmatter. It supports a single document (optionally between --- and ... markers), block mappings and sequences (including compact and indentless sequences), single-line flow collections, comments, literal and folded block scalars, single-line quoted scalars with all YAML escapes, and plain scalars resolved with the YAML 1.2 core schema (null, booleans, decimal, octal and hexadecimal integers, floats, .inf and .nan).

Mapping keys are their source text, so 1 and 01 are different keys. Nesting is limited to 100 levels. Anchors, aliases, tags, directives, complex keys, multi-line flow collections, multi-line quoted or plain scalars, and multi-document streams are rejected with a parse error rather than misread.

There are two ways to read a document:

A Yaml tree value is one of:

  • Null for null, ~, an empty value or an empty document;
  • Bool, Int (an I64; larger integers are an error) and Float for plain scalars the core schema resolves;
  • Text for quoted scalars, block scalars and any other plain scalar;
  • Sequence and Mapping for collections. Mapping entries keep their source order, and a duplicate key is an error.
is_eq : _

Compare two parsed YAML values structurally.

to_hash : _

Hash a parsed YAML value, so trees can be Dict keys or Set members.

parse_str : Str -> Try(Yaml, [InvalidYaml(Error)])

Parse one YAML configuration document from a string.

Empty input produces Null. Invalid input returns InvalidYaml with a one-based line and column. See the module description for the supported subset.

expect Yaml.parse_str("draft: false") == Ok(Mapping([{ key: "draft", value: Bool(False) }]))
parser : Parser(Bytes, Yaml)

A Parser that reads the rest of its input as one YAML document, for composing YAML with other parsers.

It always consumes all of its input. A failure is a ParseError whose offset is the byte offset of the reported line and column.

expect Utf8.parse_str(Yaml.parser, "a: 1") == Ok(Mapping([{ key: "a", value: Int(1) }]))
get : Yaml, Str -> Try(Yaml, [Missing])

The value stored under key in a mapping. Anything else, or a mapping without that key, is Err(Missing).

expect Yaml.parse_str("title: Post").map_ok(|doc| doc.get("title")) == Ok(Ok(Text("Post")))
at : Yaml, U64 -> Try(Yaml, [Missing])

The item at a zero-based index of a sequence. Anything else, or an index past the end, is Err(Missing).

expect Yaml.parse_str("[a, b]").map_ok(|doc| doc.at(1)) == Ok(Ok(Text("b")))
get_path : Yaml, List(Str) -> Try(Yaml, [Missing])

Follow a path of mapping keys and sequence indexes. A segment applied to a sequence must be a decimal index such as "0".

expect {
    doc = Yaml.parse_str("jobs:\n  build:\n    steps: [checkout, test]\n")?
    doc.get_path(["jobs", "build", "steps", "1"]) == Ok(Text("test"))
}
as_str : Yaml -> Try(Str, [WrongType])

The text of a Text value. Other values, including numbers and booleans, are Err(WrongType); use Yaml.decode to read a plain scalar such as 1.10 as text.

as_i64 : Yaml -> Try(I64, [WrongType])

The integer of an Int value, or Err(WrongType).

as_bool : Yaml -> Try(Bool, [WrongType])

The boolean of a Bool value, or Err(WrongType).

as_list : Yaml -> Try(List(Yaml), [WrongType])

The items of a Sequence value, or Err(WrongType).

expect Yaml.parse_str("[1, 2]").map_ok(|doc| doc.as_list()) == Ok(Ok([Int(1), Int(2)]))
to_inspect : Yaml -> Str

Render a parsed YAML value in Roc source-like notation for inspection.

The output is meant for debugging and test messages, not for writing YAML. Control characters in text and keys are escaped, so the output is always printable on one line.

expect Yaml.to_inspect(Sequence([Int(1), Text("a")])) == "Sequence([Int(1), Text(\"a\")])"
decode : Str -> Try(a, [InvalidYaml(Error), ..errs]) where [a.Parseable([InvalidYaml(Error), ..errs])]

Decode one YAML document straight into a Roc type, chosen by type inference.

  • A mapping decodes into a record (by field name), or a Dict with Str or integer keys. Unknown keys are skipped; use Yaml.decoder with unknown_keys: Reject to make them an error.
  • A sequence decodes into a List or, when it has exactly the right number of items, a tuple.
  • A scalar is resolved for the type that asks for it, using the YAML 1.2 core schema: Str takes any quoted or block scalar and any plain scalar except a null, so version: 1.10 stays "1.10"; Bool, the integer types, F32, F64 and Dec take the matching core-schema forms; a tag union without payloads takes the tag's name.
  • A null (null, ~ or an empty value) decodes as an empty list, dict or record, and fills a Try(_, [Null]) field with Err(Null).
  • A Try(_, [Missing]) field is Err(Missing) when its key is absent. Any other absent field fails with MissingRequiredField(name), a tag the compiler adds to the error type.

Invalid YAML and values that do not fit the type both fail with InvalidYaml, at the line and column of the offending value.

Config : { name : Str, version : Str, port : U16, debug : Try(Bool, [Missing]) }

expect {
    config : Try(Config, [InvalidYaml(Yaml.Error), MissingRequiredField(Str)])
    config = Yaml.decode("name: app\nversion: 1.10\nport: 8080\n")
    config == Ok({ name: "app", version: "1.10", port: 8080, debug: Err(Missing) })
}
decoder : DecodeOptions -> (Str -> Try(a, [InvalidYaml(Error), ..errs])) where [a.Parseable([InvalidYaml(Error), ..errs])]

Build a decoder like Yaml.decode with other conventions.

  • keys says how YAML keys spell Roc's snake_case field names: SnakeCase as written, KebabCase with dashes (user-id) or CamelCase (userId).
  • unknown_keys is Skip to ignore keys the record does not have, or Reject to fail with InvalidYaml at the first one.

Build the decoder once, for example as a top-level constant, and call it for each document.

decode_strict = Yaml.decoder({ keys: KebabCase, unknown_keys: Reject })

expect {
    result : Try({ user_id : U64 }, [InvalidYaml(Yaml.Error), MissingRequiredField(Str)])
    result = decode_strict("user-id: 7\n")
    result == Ok({ user_id: 7 })
}
Error : { line : U64, column : U64, message : Str }

Location and explanation of invalid YAML input. Lines and columns are one-based; a column counts bytes from the start of its line.

Parseable : a
    where [
        a.parser_for : Format -> (Cursor -> Try({ value : a, rest : Cursor }, errs)),
    ]

Names the requirement that a type can be decoded from YAML, so a generic function can say "YAML-decodable" without naming the format and cursor types:

load : Str -> Try(a, [InvalidYaml(Yaml.Error), ..errs]) where [a.Yaml.Parseable([InvalidYaml(Yaml.Error), ..errs])]
load = |text| Yaml.decode(text)

Format

Yaml.Format :: # (opaque)

The decoding format that Yaml.decode passes to a type's parser_for. It implements the builtin parsing protocol; you do not call its methods yourself.

rename_field : Format, Str -> Str

Spell a Roc field name as a YAML key, following the decoder's keys option.

parse_str : Format, Cursor -> Try({ value : Str, rest : Cursor }, [InvalidYaml(Error)])

Read a string: any quoted or block scalar, or a plain scalar other than null.

parse_bool : Format, Cursor -> Try({ value : Bool, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema boolean such as true or FALSE.

parse_u8 : Format, Cursor -> Try({ value : U8, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_i8 : Format, Cursor -> Try({ value : I8, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_u16 : Format, Cursor -> Try({ value : U16, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_i16 : Format, Cursor -> Try({ value : I16, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_u32 : Format, Cursor -> Try({ value : U32, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_i32 : Format, Cursor -> Try({ value : I32, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_u64 : Format, Cursor -> Try({ value : U64, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_i64 : Format, Cursor -> Try({ value : I64, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_u128 : Format, Cursor -> Try({ value : U128, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_i128 : Format, Cursor -> Try({ value : I128, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer (decimal, 0o octal or 0x hexadecimal) that fits the type.

parse_dec : Format, Cursor -> Try({ value : Dec, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer or float; .inf and .nan only where the type has them.

parse_f32 : Format, Cursor -> Try({ value : F32, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer or float; .inf and .nan only where the type has them.

parse_f64 : Format, Cursor -> Try({ value : F64, rest : Cursor }, [InvalidYaml(Error)])

Read a core-schema integer or float; .inf and .nan only where the type has them.

parse_null : Format, Cursor -> Try(Cursor, [InvalidYaml(Error)])

Succeeds only on a null, so a Try(_, [Null]) field falls back to its value parser otherwise.

parse_tag_union : Format, ParseTagUnionSpec(a), Cursor -> Try({ value : a, rest : Cursor }, [InvalidYaml(Error)])

Read a tag without payloads from a scalar holding its name.

parse_list_start : Format, Cursor -> Try([Counted({ len : U64, rest : Cursor }), Uncounted(Cursor)], [InvalidYaml(Error)])

Start a list at a sequence (or a null, as an empty list); sequences are always counted.

parse_list_next : Format, Cursor -> Try([Item(Cursor), Done(Cursor)], [InvalidYaml(Error)])

Sequences are always counted, so the driver never asks for the next item.

parse_list_after_item : Format, Cursor -> Try([Continue(Cursor), Done(Cursor)], [InvalidYaml(Error)])

Protocol step that counted YAML collections never need; it does nothing.

parse_tuple_start : Format, Cursor, U64 -> Try(Cursor, [InvalidYaml(Error)])

Tuple protocol step; a tuple needs a sequence of exactly its length.

parse_tuple_next : Format, Cursor, U64, U64 -> Try(Cursor, [InvalidYaml(Error)])

Tuple protocol step; a tuple needs a sequence of exactly its length.

parse_tuple_end : Format, Cursor, U64 -> Try(Cursor, [InvalidYaml(Error)])

Tuple protocol step; a tuple needs a sequence of exactly its length.

parse_record_start : Format, Cursor -> Try([Counted({ len : U64, rest : Cursor }), Uncounted(Cursor)], [InvalidYaml(Error)])

Start a record or dict at a mapping (or a null, as an empty one); mappings are always counted.

parse_record_field : Format, FieldNames(_shape), Cursor -> Try(
    [
        Field({ field : FieldName(_shape), rest : Cursor }),
        TryField({ name : Str, rest : Cursor }),
        TryFieldCaseless({ name : Str, rest : Cursor }),
        Continue(Cursor),
        Done(Cursor),
    ],
    [InvalidYaml(Error)],
)

Read the next mapping key for the generated record parser to match.

parse_record_after_field : Format, Cursor -> Try([Continue(Cursor), Done(Cursor)], [InvalidYaml(Error)])

Mappings are always counted, so the driver never asks whether another field follows.

skip_record_field : Format, Cursor -> Try(Cursor, [InvalidYaml(Error)])

Skip the value of a key the record lacks, or reject it when unknown_keys is Reject.

parse_dict_start : Format, Cursor -> Try([Counted({ len : U64, rest : Cursor }), Uncounted(Cursor)], [InvalidYaml(Error)])

Start a record or dict at a mapping (or a null, as an empty one); mappings are always counted.

parse_dict_next : Format, Cursor -> Try([Entry(Cursor), Done(Cursor)], [InvalidYaml(Error)])

Protocol step that counted YAML collections never need; it does nothing.

parse_dict_after_key : Format, Cursor -> Try(Cursor, [InvalidYaml(Error)])

Protocol step that counted YAML collections never need; it does nothing.

parse_dict_after_entry : Format, Cursor -> Try([Continue(Cursor), Done(Cursor)], [InvalidYaml(Error)])

Protocol step that counted YAML collections never need; it does nothing.

invalid_value : Format, Cursor -> [InvalidYaml(Error)]

The error for a value the generated parser cannot use.

parse_key_str : Format, Cursor -> Try({ value : Str, rest : Cursor }, [InvalidYaml(Error)])

Read a mapping key as a Dict key.

parse_key_u8 : Format, Cursor -> Try({ value : U8, rest : Cursor }, [InvalidYaml(Error)])

Read a mapping key as a Dict key.

parse_key_i8 : Format, Cursor -> Try({ value : I8, rest : Cursor }, [InvalidYaml(Error)])

Read a mapping key as a Dict key.

parse_key_u16 : Format, Cursor -> Try({ value : U16, rest : Cursor }, [InvalidYaml(Error)])

Read a mapping key as a Dict key.

parse_key_i16 : Format, Cursor -> Try({ value : I16, rest : Cursor }, [InvalidYaml(Error)])

Read a mapping key as a Dict key.

parse_key_u32 : Format, Cursor -> Try({ value : U32, rest : Cursor }, [InvalidYaml(Error)])

Read a mapping key as a Dict key.

parse_key_i32 : Format, Cursor -> Try({ value : I32, rest : Cursor }, [InvalidYaml(Error)])

Read a mapping key as a Dict key.

parse_key_u64 : Format, Cursor -> Try({ value : U64, rest : Cursor }, [InvalidYaml(Error)])

Read a mapping key as a Dict key.

parse_key_i64 : Format, Cursor -> Try({ value : I64, rest : Cursor }, [InvalidYaml(Error)])

Read a mapping key as a Dict key.

Cursor

Yaml.Cursor :: # (opaque)

The read position that Yaml.decode threads through a type's parser: the document as a flat list of events and an index into it.