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.

For source-preserving syntax highlighting, Yaml.tokenize_str and Yaml.tokenize_bytes return original byte spans and diagnostics even for incomplete snippets. Yaml.highlight_category provides theme categories.

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.
tokenize_str : Str -> Tokenized

Tokenize source text for highlighting, including incomplete YAML snippets. Slicing each token interval from source and concatenating them reproduces the input exactly, including comments, whitespace, CRLF, and Unicode. Anchors, aliases, tags, directives and document markers are recognized lexically even when Yaml.parse_str rejects the document.

tokenize_bytes : List(U8) -> Tokenized

Tokenize arbitrary bytes without discarding invalid UTF-8. Diagnostics report malformed encoding; tokens still retain every original byte.

highlight_category : Token -> HighlightCategory

Map a token to a theme category without rendering or changing its text. Mapping keys use PropertyName; scalar values use their core-schema category. Quoted values and block-scalar scripts remain StringText.

is_eq : _

Compare two parsed YAML values structurally.

to_hash : _

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

from_quote : Str -> Try(Yaml, [BadQuotedBytes(Str)])

Read a quoted YAML tree during compilation, rejecting invalid input and unquoted legacy boolean values such as yes and off.

validate : ValidationProps -> Try(Yaml, [BadQuotedBytes(Str)])

Validate one complete document, reporting its source name and position. Unlike Yaml.parse_str, this also rejects enabled lint checks. Legacy boolean checks inspect unquoted values, never mapping keys or quoted text.

validate_as : DecodeValidationProps -> Try(a, [BadQuotedBytes(Str)]) where [a.Parseable([InvalidYaml(Error), ..errs])]

Validate and decode one document into the inferred Roc type. All existing decoder types, including U128 and strings such as 1.10, are preserved. Use unknown_keys: Reject to reject fields the result type does not have.

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.

Span : { start : U64, end : U64 }

Half-open byte interval in the original source. Offsets count UTF-8 bytes, not characters, and preserve CRLF without normalization.

TokenKind : [Whitespace, Comment, PlainScalar, SingleQuotedScalar, DoubleQuotedScalar, MappingValue, SequenceEntry, ExplicitKey, FlowSequenceStart, FlowSequenceEnd, FlowMappingStart, FlowMappingEnd, FlowEntry, BlockScalarHeader, BlockScalarContent, Anchor, Alias, Tag, Directive, DocumentStart, DocumentEnd, Invalid]

Stable lexical kinds; punctuation spelling is available in the source span. Quotes include their delimiters. Block scalar content includes its original indentation and line endings. Whitespace and comments are retained.

TokenRole : [Key, Value, Other]

A lexical mapping-key role, a scalar value, or punctuation/trivia. Roles describe source context, not the validity or decoded type of a node.

ScalarCategory : [Text, Boolean, Null, Number]

YAML 1.2 core-schema category of a plain scalar. Quoted and block scalars are Text. Numeric classification does not depend on machine integer range.

Token : { kind : TokenKind, start : U64, end : U64, role : TokenRole, scalar : ScalarCategory }

A nonempty original-source interval. Tokens form a contiguous partition of the entire input: no gaps, overlap, synthetic tokens, or end-of-file token.

DiagnosticCode : [UnclosedQuote, InvalidEscape, UnclosedCollection, UnexpectedDelimiter, InvalidIndentation, InvalidBlockHeader, InvalidCharacter, InvalidUtf8, ResourceLimit]

Lexical problems found while preserving displayable source.

Diagnostic : { code : DiagnosticCode, start : U64, end : U64 }

A lexical issue located by its original-source byte interval.

TokenStatus : [Complete, Recovered, Limited]

Complete means no lexical issue was detected; it does not establish YAML semantic validity. Recovered preserves malformed source with diagnostics. Limited preserves the input after reaching 256 open flow collections; the remaining bytes become one Invalid token with a ResourceLimit issue.

Tokenized : { source : List(U8), tokens : List(Token), diagnostics : List(Diagnostic), status : TokenStatus }

Source retained once, its lossless token partition, and located diagnostics. Tokenization is independent of the semantic parser's supported subset.

HighlightCategory : [Trivia, CommentText, PropertyName, StringText, NumericLiteral, BooleanLiteral, NullLiteral, Symbol, Name, TypeName, InvalidText]

Theme-independent categories, usable by HTML, PDF, or terminal renderers.

Lints

:= { legacy_booleans : Bool ?? True }

Checks for accepted YAML that is commonly mistaken for another value. Set a check to False to accept that spelling deliberately.

ValidationProps

:= { source : Str, name : Str ?? "<literal>", lints : Lints ?? {} }

Checked tree parsing. Omitted fields use the documented defaults.

DecodeValidationProps

:= {
    source : Str,
    name : Str ?? "<literal>",
    lints : Lints ?? {},
    keys : [SnakeCase, KebabCase, CamelCase] ?? SnakeCase,
    unknown_keys : [Skip, Reject] ?? Skip,
}

Checked type-directed decoding, with the same key conventions as Yaml.decode.

Decoded

:= { value : a }

A typed YAML quotation. Access the decoded application value with .value.

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.