1. roc-parser: A manual for parsing text and data in Roc§

Parse text and data in Roc. roc-parser gives you small parsers that combine into larger ones, and ready-made parsers for CSV, YAML, XML, JSON, Markdown and HTTP/1.1 built from the same pieces.

Browse the API reference · Download this manual as a PDF.

1.1. Choose a path§

New to roc-parser

Read What roc-parser is, then Getting started, then Your first parser.

Parsing a format roc-parser already supports

Go to its guide: Read CSV data, Read YAML configuration and frontmatter, Read XML documents, Read and write JSON, Markdown or HTTP messages.

Writing your own parser

Read Combinators by task, Parse a format of your own and Report parse errors.

Looking something up

Modules, Conformance, Performance, Compatibility and Glossary.

Not sure whether you need a parser

Read Choosing how to read your input.

Contributing to roc-parser

Start with Contributing to roc-parser.

Curious where the design comes from

Read Where the design comes from.

2. Start here§

2.1. What roc-parser is§

roc-parser turns text into typed Roc values. You give it a string, such as the contents of a CSV file, a YAML configuration, or an HTTP request, and it gives back either a Roc value you can use directly or an error that says what was wrong with the input.

It is written in pure Roc, so it works with any platform, and it comes in two layers:

This manual is for Roc programmers. You do not need to know anything about parsing theory.

2.1.1. The problem it solves§

Most programs start by reading text that someone else wrote. Splitting a line on commas, or matching it with a regular expression, works until the input contains a quoted comma, a missing field, or a value of the wrong kind. Then the code either crashes, silently produces wrong data, or grows a pile of special cases.

A parser describes the shape of valid input once, as a Roc value, and checks every input against it. With roc-parser:

Compared with a regular expression, a combinator parser is longer to write for a one-off match, but it can describe nested structure (a regular expression cannot match balanced brackets), it produces a value rather than a list of captured substrings, and it reads as ordinary Roc code.

2.1.2. What a first success looks like§

This program decodes a CSV file with a header row into typed records and prints them. Getting started walks through it.

import cli.Stdout
import parser.CSV

Planet : { name : Str, moons : U64 }

input =
	\\name,moons
	\\Mercury,0
	\\Earth,1
	\\Mars,2

planets : Str -> Try(List(Planet), [InvalidCsv(CSV.Error), MissingRequiredField(Str)])
planets = |text| CSV.parse(text)

describe : Str -> Str
describe = |text| {
	match planets(text) {
		Ok(rows) => Str.join_with(rows.map(|p| "${p.name}: ${p.moons.to_str()} ${if (p.moons == 1) "moon" else "moons"}"), "\n")
		Err(InvalidCsv(error)) => "line ${error.line.to_str()}: ${error.message}"
		Err(MissingRequiredField(name)) => "no ${name} column"
	}
}

main! = |_args| {
	Stdout.line!(describe(input))
}

Output:

Mercury: 0 moons
Earth: 1 moon
Mars: 2 moons

2.1.3. Supported formats§

Each module follows a published specification. Where a module implements only part of one, its chapter says which part, and input outside that part is rejected with an error rather than misread.

ModuleReadsSpecification
CSVComma-separated rows decoded into your own record typeRFC 4180, with the usual relaxations (any line ending, rows of different lengths)
YamlConfiguration files and Markdown frontmatter, as a tree or your own record typeA documented subset of YAML 1.2: one document, no anchors, aliases or tags
XmlA whole XML document as a treeXML 1.0 (Fifth Edition) well-formedness, without document type declarations or namespaces
JsonValueA JSON document of any shape as a lossless tree, and JSON written back outRFC 8259, with opt-in relaxations (comments, trailing commas, a byte order mark, NaN and Infinity) and opt-in checks (size limits, the top-level shape, I-JSON numbers)
MarkdownA Markdown document as a syntax treeCommonMark 0.31.2 with GitHub Flavored Markdown tables, task lists and strikethrough
HTTPOne HTTP/1.x request or responseRFC 9112 message syntax and RFC 9110 field syntax
Parser, Utf8Anything you describe with combinatorsNot applicable

The format chapters describe each module’s exact behaviour. The API reference lists every public function.

2.1.4. How the pieces fit together§

A combinator is a function that takes parsers and returns a new parser. The Utf8 module provides parsers for the smallest pieces of text, such as one byte, a digit, or an exact word. The Parser module combines them: one after another, one of several alternatives, repeated, or with the result transformed. Each format module also exposes its parser as a Parser value, so your own parsers and the library’s can be mixed.

Small parsers combine into larger ones
Figure 1. Small parsers combine into larger ones

Your first parser teaches the combinators by building a small parser one step at a time.

2.1.5. When to choose something else§

roc-parser fits programs that read text formats of moderate size completely into memory and want typed results with clear errors. Consider something else when:

2.1.6. Stability§

roc-parser tracks recent nightly builds of the Roc compiler. Because Roc is still changing, the package’s API may change between releases. Each release names the compiler build it was tested with, and the release notes describe any changes you need to make when upgrading.

2.2. Getting started§

In this chapter you add roc-parser to a Roc app and use it to read three lines of CSV into typed records. At the end the app prints one line per record. You need a terminal, an internet connection, and a little Roc: how to write a function, a record, and a match.

2.2.1. Before you start§

You need:

Check the compiler:

roc version

It prints the name of the nightly build, which should match the one in the release notes:

Roc compiler version nightly-<date>-<commit>

2.2.2. Add the package§

A Roc app names each package it uses by URL in its header. The compiler downloads the package the first time it builds the app and checks it against the hash in the URL.

  1. Open the roc-parser releases page and choose the newest release.

  2. Under Assets, find the file ending in .tar.zst and copy its link.

  3. Put that link in your app’s header under a short name. This manual uses parser.

Create a file named main.roc with this header, replacing the parser URL with the one you copied:

app [main!] {
	cli: platform "https://github.com/roc-lang/basic-cli/releases/download/0.26.0/GddgvsGjiNXrHFW6au5i5dUDbTs8FinQdrgE2Gqmw53t.tar.zst",
	parser: "https://github.com/lukewilliamboswell/roc-parser/releases/download/<version>/<hash>.tar.zst",
}

The examples in this manual are tested against the package source in the repository, so their headers say parser: "../../package/main.roc" instead. Everything else in them is the same.

For complete programs to start from, download roc-parser-examples-<version>.zip from the same release. Its apps are already pinned to that release; unzip it and follow the README.md inside, which runs them with roc <name>.roc from its examples/ directory.

2.2.3. Read CSV into records§

Add the rest of the program below the header:

import cli.Stdout
import parser.CSV

Planet : { name : Str, moons : U64 }

input =
	\\name,moons
	\\Mercury,0
	\\Earth,1
	\\Mars,2

planets : Str -> Try(List(Planet), [InvalidCsv(CSV.Error), MissingRequiredField(Str)])
planets = |text| CSV.parse(text)

describe : Str -> Str
describe = |text| {
	match planets(text) {
		Ok(rows) => Str.join_with(rows.map(|p| "${p.name}: ${p.moons.to_str()} ${if (p.moons == 1) "moon" else "moons"}"), "\n")
		Err(InvalidCsv(error)) => "line ${error.line.to_str()}: ${error.message}"
		Err(MissingRequiredField(name)) => "no ${name} column"
	}
}

main! = |_args| {
	Stdout.line!(describe(input))
}

Run it:

roc main.roc

Output:

Mercury: 0 moons
Earth: 1 moon
Mars: 2 moons

The first run takes longer while the compiler downloads the platform and the package.

2.2.4. What the program does§

import parser.CSV makes the CSV module from the package named parser available.

Planet is the record type each row becomes. The first line of the input is a header row, and each field of Planet reads the column with the same name.

planets calls CSV.parse, which splits the text into rows and decodes each row into a record. CSV.parse does not take the row type as an argument: the compiler infers it from the type annotation on planets, and finds the decoding code through the static dispatch method parser_for, which every record type has. Change the annotation and the same call decodes a different record.

The result is Ok with a list of Planet records, or Err with one of two tags:

describe handles all three cases with a match. Nothing is printed until every row has been read successfully.

To see the error path, change Mars,2 to Mars,two and run the program again. It prints a message that names line 4 and quotes two. Rename the moons column and it prints no moons column. Report parse errors shows how to report every kind of failure, and Read CSV data shows optional columns, empty cells and hand-built row parsers that match columns by position.

2.2.5. Next steps§

2.3. Choosing how to read your input§

Roc gives you four ways to turn text into values, and roc-parser provides two of them. This chapter helps you pick one before you write any code. In short:

  1. Decode into your own type when the input is a standard format and you already know the shape you want. The record type is the specification, and there is almost no code to write.

  2. Read a document tree with one of roc-parser’s format modules when you do not know the shape in advance, or you need what a record would throw away.

  3. Build a parser with combinators when the format is your own, or you need control over exactly what is accepted and what the errors say.

  4. Use no parser at all when a Str function does the job, or when the input is too large or too hot for any of these.

The examples come from choosing-approaches.roc; every expect in it passes.

2.3.1. Decode into your own type§

Roc has type-directed decoding built in. A format (such as the builtin Json) knows how to read strings, numbers, lists and records. Your type knows which of those it is made of. The compiler combines the two, so the annotation alone decides what is accepted:

# (a) Type-directed decoding: the record type is the whole specification.
Service : { name : Str, port : U64 }

decode_service : Str -> Try(Service, [InvalidJson(Str), MissingRequiredField(Str)])
decode_service = |json| Json.parse(json)

expect decode_service("{\"name\": \"web\", \"port\": 8080}") == Ok({ name: "web", port: 8080 })
expect decode_service("{\"name\": \"web\"}") == Err(MissingRequiredField("port"))

The parsers chapter of the language reference describes the mechanism. Every type has a parser_for method, which the compiler derives for records, lists and tuples, and a format is a type module whose methods read one value at a time. Static dispatch joins the two. A field of type Try(_, [Missing]) is optional, a missing required field is reported by name, and when the parser is a top-level constant it is assembled at compile time.

Choose this when:

The builtin Json is such a format, for JSON whose shape you know in advance. For dynamic JSON, whose keys are not Roc identifiers or whose arrays mix shapes, or that must keep member order and every digit of its numbers, read a document tree with roc-parser’s JsonValue instead (see Read and write JSON).

roc-parser’s CSV and Yaml modules are formats for this mechanism. CSV.parse reads a file with a header row into a list of records, matching columns to fields by name, and Yaml.decode reads a document into a record. Neither takes the type as an argument: the annotation chooses it. A required field with no column or key fails with MissingRequiredField(name), a tag the compiler adds to the error type, so the annotation names it alongside the format’s own error:

# roc-parser's CSV and Yaml modules are formats for the same mechanism.
Planet : { name : Str, moons : U64, rings : Try(Bool, [Missing]) }

planets : Str -> Try(List(Planet), [InvalidCsv(CSV.Error), MissingRequiredField(Str)])
planets = |text| CSV.parse(text)

expect planets("name,moons\nMars,2\n") == Ok([{ name: "Mars", moons: 2, rings: Err(Missing) }])
expect planets("name\nMars\n") == Err(MissingRequiredField("moons"))

service : Str -> Try(Service, [InvalidYaml(Yaml.Error), MissingRequiredField(Str)])
service = |text| Yaml.decode(text)

expect service("name: web\nport: 8080\n") == Ok({ name: "web", port: 8080 })
expect service("name: web\n") == Err(MissingRequiredField("port"))

Its limits come from the same design. The format decides the representation, so you cannot ask it to accept yes as a boolean or to read a date in your own layout. Where a wrong value is depends on the format: CSV.parse reports the record, field, line and column, and Yaml.decode the line and column. And the type must be known when you compile.

2.3.2. Read a document tree§

roc-parser’s Yaml, Xml, JsonValue and Markdown modules read a whole document into a tree that mirrors the format, and HTTP reads a message into a record of its parts. You then walk the tree with pattern matching, or with the tree methods such as get_path and as_i64 on a Yaml or JsonValue value and attribute and children_named on an Xml.Node:

# (b) A ready-made format parser: read the whole tree, then decide what to
# do with each key, whatever keys the file happens to contain.
top_level_keys : Str -> Try(List(Str), [NotAMapping, InvalidYaml(Yaml.Error)])
top_level_keys = |text| {
	match Yaml.parse_str(text)? {
		Mapping(entries) => Ok(entries.map(|entry| entry.key))
		_ => Err(NotAMapping)
	}
}

expect top_level_keys("name: web\nport: 8080\nextra: [1, 2]") == Ok(["name", "port", "extra"])

# The tree methods follow a path without a match per level.
port_of : Str -> Try(I64, [Missing, WrongType, InvalidYaml(Yaml.Error)])
port_of = |text| Yaml.parse_str(text)?.get_path(["server", "port"])?.as_i64()

expect port_of("server:\n  port: 8080\n") == Ok(8080)
expect port_of("server: {}\n") == Err(Missing)

Choose this when:

The cost is code: you write the walk, and a missing or mistyped key is something you check, not something the type system checks for you. The format chapters (Read YAML configuration and frontmatter, Read XML documents, Read and write JSON, Markdown and HTTP messages) show helper functions that keep the walk short.

2.3.3. Build a parser with combinators§

When no format module reads your input, write a parser from the Parser and Utf8 combinators. A parser is a value made of smaller parsers, so the code has the shape of the grammar:

# (c) A parser of your own: a legacy "host:port" line, where the port must
# be digits and the grammar is yours to define.
Endpoint : { host : Str, port : U64 }

endpoint : Parser(Utf8.Bytes, Endpoint)
endpoint =
	Parser.const(|host| |port| { host, port })
		.keep(Parser.chomp_while(|b| b != ':').map(Str.from_utf8_lossy))
		.skip(Utf8.codeunit(':'))
		.keep(Utf8.digits)

expect Utf8.parse_str(endpoint, "example.com:443") == Ok({ host: "example.com", port: 443 })
expect Utf8.parse_str(endpoint, "example.com:https").is_err()

Choose this when:

Combinators are also how you extend a format module. CSV.record and CSV.field are combinators, every format module has a parser value to embed, and you can mix Utf8 parsers into any parser of your own. A combinator parser’s failure is the furthest failure, with a byte offset. Your first parser teaches the method and Parse a format of your own applies it to a real format.

2.3.4. Use no parser at all§

Parser combinators are not always the right tool, and roc-parser’s own format modules show where the line falls.

The input has no structure worth a grammar

One separator, no quoting and no nesting is a job for Str.split_on, Str.trim and friends. A parser would be longer and no clearer:

# (d) No parser at all: one separator and no nesting is a job for `Str`.
fields : Str -> List(Str)
fields = |line| line.split_on("\t")

expect fields("a\tb\tc") == ["a", "b", "c"]
The input is very large or arrives as a stream

Every parser in this package reads a complete input held in memory, and the result holds copies of the text it read. For a multi-gigabyte log or a network connection, read and process one record at a time with a hand-written loop over the bytes, or use a streaming library.

The parser is on a hot path

Each combinator adds a function call and an intermediate Try. That is cheap for configuration files and messages, but Add a format module records that the CSV, XML, HTTP and Markdown modules moved their document-level parsing to byte scanners with an explicit stack, to stay linear and avoid deep recursion; the JSON module was written that way from the start. Do the same when profiling shows the parser matters. Performance compares the built-in parsers with Go, Rust and Python libraries.

The grammar is ambiguous or left-recursive

Combinators take the first alternative that succeeds and cannot handle a rule that starts with itself, such as expr = expr "-" term. A large grammar written for a parser generator, such as an existing yacc or ANTLR grammar, is often better served by a generator, which checks the grammar for conflicts before any input is read. Where the design comes from explains these limits.

2.3.5. Decision table§

You needDecode into a typeDocument treeCombinatorsNo parser
A standard format (CSV, YAML) into known records, with little codeBestPossiblePossibleNo
Any keys or structure, discovered at run timeNoBestPossibleNo
Mapping key order or XML mixed content keptNoYesYesNo
Line and column in errorsYes for CSV and YamlYesA byte offsetNo
Your own or a legacy format, a protocol or a small languageNoNoBestIf trivial
Values validated while parsing, with your own messagesNoAfterwardsBestBy hand
Read one message and keep the restNoHTTP.parse_request returns the restYesBy hand
Very large or streaming input, or a hot pathNoNoNoBest
Ambiguous or left-recursive grammarNoNoNoUse a parser generator

2.3.6. Decision flow§

The same choice as a sequence of questions:

Choosing how to read your input
Figure 2. Choosing how to read your input

The questions are ordered from least code to most. You can also combine answers: read a Markdown file as a tree, read its frontmatter as a YAML tree, and parse one custom field inside it with a combinator.

2.4. Your first parser§

In this tutorial you write a parser for a small settings format, one line per setting:

name="demo"
port=8080
debug=true

By the end you have a parser that turns that text into a list of typed records, and you know how to read its errors. Along the way you meet the combinators that every parser in this package is built from.

You need the setup from Getting started. Each step is a complete program in the repository’s docs/examples directory; the step’s heading names the file. Run a step with roc <file> and its tests with roc test <file>.

2.4.1. Step 1: read a key§

combinator-primer-step1.roc

A key is one or more lowercase letters or underscores. Start with a parser for one such byte, repeat it, and turn the bytes into a Str:

is_key_byte : U8 -> Bool
is_key_byte = |b| (b >= 'a' and b <= 'z') or b == '_'

key : Parser(Utf8.Bytes, Str)
key =
	Utf8.codeunit_satisfies(is_key_byte)
		.one_or_more()
		.map(Str.from_utf8_lossy)

Three ideas appear here:

Utf8.parse_str runs a parser on a whole string. These tests pass:

expect Utf8.parse_str(key, "name") == Ok("name")
expect Utf8.parse_str(key, "Name").is_err()

The second fails because N is not a lowercase letter, so not even one key byte can be read.

2.4.2. Step 2: read a whole setting§

combinator-primer-step2.roc

A setting is a key, an =, and a value. For now, the value is everything up to the end of the line:

value : Parser(Utf8.Bytes, Str)
value =
	Parser.chomp_while(|b| b != '\n')
		.map(Str.from_utf8_lossy)

Entry : { key : Str, value : Str }

entry : Parser(Utf8.Bytes, Entry)
entry =
	Parser.const(|k| |v| { key: k, value: v })
		.keep(key)
		.skip(Utf8.codeunit('='))
		.keep(value)

This is the pattern for reading things in sequence:

  1. Parser.const(f) is a parser that reads nothing and produces f, the function that builds the result. f takes its arguments one at a time (|k| |v| ...), because they arrive one at a time.

  2. .keep(p) runs p next and passes its result to the function.

  3. .skip(p) runs p next and throws its result away. The = must be there, but you do not need it in the record.

Parser.chomp_while(pred) reads bytes while pred accepts them. It always succeeds, even when it reads nothing, which is why "empty=" gives an empty value:

expect Utf8.parse_str(entry, "name=roc") == Ok({ key: "name", value: "roc" })
expect Utf8.parse_str(entry, "empty=") == Ok({ key: "empty", value: "" })

Running the step prints:

Ok({ key: "name", value: "roc" })

2.4.3. Step 3: choose between kinds of value§

combinator-primer-step3.roc

A value is a number, true or false, or text in double quotes. Write one parser for each kind, then try them in order with Parser.one_of:

Value : [Number(U64), Flag(Bool), Text(Str)]

number : Parser(Utf8.Bytes, Value)
number = Utf8.digits.map(|n| Number(n))

flag : Parser(Utf8.Bytes, Value)
flag =
	Parser.one_of([
		Parser.const(Flag(Bool.True)).skip(Utf8.string("true")),
		Parser.const(Flag(Bool.False)).skip(Utf8.string("false")),
	])

text : Parser(Utf8.Bytes, Value)
text =
	Parser.chomp_while(|b| b != '"')
		.map(|bytes| Text(Str.from_utf8_lossy(bytes)))
		.between(Utf8.codeunit('"'), Utf8.codeunit('"'))

value : Parser(Utf8.Bytes, Value)
value = Parser.one_of([number, flag, text])

Parser.one_of tries each parser on the same input and returns the first success. If one fails part-way through, the next one starts again from the beginning, so text does not see input already examined by number or flag.

Utf8.digits reads a whole number as a U64, and Utf8.string("true") reads exactly that text. .between(open, close) reads open, the parser, and close, and keeps only the middle result.

expect Utf8.parse_str(value, "8080") == Ok(Number(8080))
expect Utf8.parse_str(value, "true") == Ok(Flag(Bool.True))
expect Utf8.parse_str(value, "\"hello world\"") == Ok(Text("hello world"))
expect Utf8.parse_str(value, "maybe").is_err()

The entry parser from step 2 now uses this value. Running the step prints:

Ok({ key: "port", value: Number(8080) })

2.4.4. Step 4: read every line, and read the errors§

combinator-primer-step4.roc

A settings file is entries separated by line breaks. sep_by reads that shape and drops the separators:

config : Parser(Utf8.Bytes, List(Entry))
config = entry.sep_by(Utf8.codeunit('\n'))
expect
	Utf8.parse_str(config, "port=8080\ndebug=true")
		== Ok([{ key: "port", value: Number(8080) }, { key: "debug", value: Flag(Bool.True) }])

Utf8.parse_str returns one of three results, and a real program should handle each:

report : Str -> Str
report = |source| {
	match Utf8.parse_str(config, source) {
		Ok(entries) => "parsed ${entries.len().to_str()} entries"
		Err(ParseError({ message, offset })) => "failed at byte ${offset.to_str()}: ${message}"
	}
}

report_entry : Str -> Str
report_entry = |source| {
	match Utf8.parse_str(entry, source) {
		Ok(_) => "parsed one entry"
		Err(ParseError({ message, offset })) => "failed at byte ${offset.to_str()}: ${message}"
	}
}

main! = |_args| {
	Stdout.line!(report("name=\"demo\"\nport=8080\ndebug=true"))?
	Stdout.line!(report("name=\"demo\"\nport=eighty"))?
	Stdout.line!(report("name=\"demo\"\n"))?
	Stdout.line!(report_entry("port=eighty"))
}

Output:

parsed 3 entries
failed at byte 17: expected char `"`
failed at byte 12: expected a codeunit satisfying a condition, but input was empty.
failed at byte 5: expected char `"`

Each line of output shows one outcome:

  1. The whole input matched: Ok.

  2. port=eighty is not a valid entry, so sep_by stopped after the first entry and did not reach the end of the input. parse_str reports the furthest failure instead: none of the value parsers accepted eighty, at byte 17.

  3. The trailing line break is a separator with no entry after it, so the parse fails at the end of the input, where a key was expected. Real files often end with a line break; Parse a format of your own shows one way to accept it.

  4. Parsing a single bad entry directly gives the same failure, at byte 5.

The second line is worth remembering. A repetition such as sep_by or many stops at the first element it cannot read, but that element’s failure is kept, so a mistake in the middle of a file is reported where it is. Report parse errors explains how to get better messages.

2.4.5. What you have learned§

Combinators by task covers the remaining combinators and the rules for alternatives and repetition in more depth.

3. Guides§

3.1. Read CSV data§

This chapter shows how to turn comma-separated text into Roc values: as records chosen by the type you ask for, as tuples when the file has no header, as raw fields, and with hand-built record parsers when a column needs custom handling. It also shows how to tell the user which row was wrong. It is for Roc programmers who have added parser to their app’s dependencies.

Every example on this page comes from docs/examples/csv-guide.roc, which CI runs, and imports parser.CSV, parser.Parser and parser.Utf8.

3.1.1. Decode rows into records§

When the first row names the columns, CSV.parse decodes every other row into the record type you annotate. Each field reads the column whose header is exactly its name, so the column order does not matter:

Product : { name : Str, quantity : U64, price : Dec }

products : Str -> Try(List(Product), [InvalidCsv(CSV.Error), MissingRequiredField(Str)])
products = |text| CSV.parse(text)

print_products! : Str => Try({}, _)
print_products! = |text| {
	match products(text) {
		Ok(found) =>
			for p in found {
				Stdout.line!("${p.name}: ${p.quantity.to_str()} at ${p.price.to_str()}")?
			}

		Err(InvalidCsv({ line, column, message, record: _, field: _ })) =>
			Stdout.line!("line ${line.to_str()}, column ${column.to_str()}: ${message}")?

		Err(MissingRequiredField(name)) =>
			Stdout.line!("no column named ${name}")?
	}
	Ok({})
}

Running it on a good file, on a file without a quantity column, and on a file whose second data row is short:

print_products!("name,price,quantity\nbolt,0.15,250\nnut,.05,+40\n")?
print_products!("name,price\nbolt,0.15\n")?
print_products!("name,price,quantity\nbolt,0.15,250\nnut,0.05\n")?
Output
bolt: 250 at 0.15
nut: 40 at 0.05
no column named quantity
line 3, column 1: the record has 2 fields, fewer than the 3 columns

MissingRequiredField(name) is not declared by CSV.parse: the compiler adds it to the error type of any decoder whose record has required fields, so include it in your annotation. Columns that no field names are ignored, and when two columns share a name the last one wins.

A cell decodes according to its field’s type:

TypeAccepted cell text
StrAny text, kept exactly, including leading and trailing spaces.
Booltrue or false in any letter case.
U8 … U128, I8 … I128ASCII digits with an optional leading `` (or - for signed types) that fit in the type. Spaces, _ and 0x prefixes are rejected.
DecAn optional sign, digits and an optional fraction: 1.50, -2, .5.
F32, F64An optional sign, then inf, infinity or nan in any letter case, or a decimal number with an optional fraction and exponent: 12, 1.5, .5, 5., 1e-3, 2.5E+10. Values too large become infinity and values too small become zero.
Tags without payloadsThe tag name, such as Active.

These match Rust’s from_str for each number type. A field that is a record, a tuple or a tag with a payload fails with an InvalidCsv error when it is decoded; a field that is a list or a dictionary is a compile-time error, because a CSV cell cannot hold one.

3.1.1.1. Optional columns, empty cells and header spelling§

A field of type Try(a, [Missing]) is Err(Missing) when the file has no such column. A field of type Try(a, [Null]) is Err(Null) when its cell is empty. CSV.parse_normalized matches header names after trimming spaces, lowercasing ASCII letters and turning runs of spaces, - and _ into one _, so First Name, first-name and FIRST_NAME all fill first_name:

Contact : {
	name : Str,
	email : Try(Str, [Missing]),
	age : Try(U64, [Null]),
	status : [Active, Inactive],
}

contacts : Str -> Try(List(Contact), [InvalidCsv(CSV.Error), MissingRequiredField(Str)])
contacts = |text| CSV.parse_normalized(text)

describe_contact : Contact -> Str
describe_contact = |{ name, email, age, status }| {
	email_text =
		match email {
			Ok(address) => address
			Err(Missing) => "no email column"
		}
	age_text =
		match age {
			Ok(years) => years.to_str()
			Err(Null) => "age not given"
		}
	status_text =
		match status {
			Active => "active"
			Inactive => "inactive"
		}
	"${name} (${email_text}, ${age_text}, ${status_text})"
}

For "Name,Age,Status\nAda,36,Active\nAlan,,Inactive\n" this prints:

Output
Ada (no email column, 36, active)
Alan (no email column, age not given, inactive)

3.1.1.2. Row width and blank lines§

Blank lines are skipped. Every other row must have exactly as many fields as the header: a shorter row and a longer row are both an InvalidCsv error naming the row, so that a misplaced comma cannot shift values into the wrong columns. A header with no data rows, and empty input, decode to an empty list.

3.1.2. Files without a header§

CSV.parse_headerless decodes every row into a tuple, one element per column, with the same cell rules. Every row must have as many fields as the tuple has elements:

points : Str -> Try(List((Str, F64, F64)), [InvalidCsv(CSV.Error)])
points = |text| CSV.parse_headerless(text)

Output
Ok([("home", -33.87, 151.21), ("work", -33.86, 151.2)])

3.1.3. Read every field as raw bytes§

Use CSV.parse_records when the columns vary or you want to inspect rows yourself. It returns one CSV.Record, a List(List(U8)), per row:

raw_records : Str -> Str
raw_records = |text| {
	match CSV.parse_records(text) {
		Ok(records) =>
			Str.join_with(records.map(|fields| Str.join_with(fields.map(Str.from_utf8_lossy), " | ")), "\n")

		Err(InvalidCsv({ message, line, column, record: _, field: _ })) =>
			"not valid CSV at ${line.to_str()}:${column.to_str()}: ${message}"
	}
}

Given a header row, a quoted field containing "" and ,, and a row whose last field is empty:

Stdout.line!(raw_records("name,note\r\nwidget,\"says \"\"hi\"\", twice\"\nempty,"))?
Output
name | note
widget | says "hi", twice
empty | 

CSV.split_header separates the first record as column names:

column_names : Str -> Str
column_names = |text| {
	match CSV.parse_records(text) {
		Ok(records) => {
			{ header, rows } = CSV.split_header(records)
			"${Str.join_with(header, ", ")}; ${rows.len().to_str()} rows"
		}

		Err(_) => "not valid CSV"
	}
}

Output
sku, count; 2 rows

CSV.parser is the same reader as a Parser(Utf8.Bytes, List(CSV.Record)), for use inside larger parsers. It accepts any bytes.

3.1.4. Hand-built record parsers§

When a column needs custom handling, such as a list stored in one cell, describe one row with CSV.record, giving it a function that takes one argument per column, then add one .keep(CSV.field(...)) per column in order. A field parser is any Parser(Utf8.Bytes, a); CSV.string, CSV.u64 and CSV.f64 read cells with the rules above:

Movie : { title : Str, year : U64, cast : List(Str) }

cast : Parser(Utf8.Bytes, List(Str))
cast = CSV.string.map(|text| Str.split_on(text, ";"))

movie : Parser(CSV.Record, Movie)
movie =
	CSV.record(|title| |year| |actors| { title, year, cast: actors })
		.keep(CSV.field(CSV.string))
		.keep(CSV.field(CSV.u64))
		.keep(CSV.field(cast))

Run it on text with CSV.parse_with, or on records you already have with CSV.decode. Columns are matched by position, and the record parser must read every field in the row: a row with more fields than .keep calls fails, and so does a row with fewer.

3.1.5. Report which row failed§

Every function stops at the first problem and returns InvalidCsv(error). The CSV.Error record says where the problem is:

FieldMeaning
record, fieldThe one-based record and field numbers. Records count every record of the input, including the header row and blank lines.
line, columnThe one-based line and byte column where the field (or, when the field does not exist, the record) starts. They are 0 from CSV.decode, which does not see the text.
messageWhat is wrong. It quotes at most a short excerpt of the field, so it is safe to show for any input.
describe : Str -> Str
describe = |text| {
	match CSV.parse_with(movie, text) {
		Ok(movies) => "${movies.len().to_str()} movies"
		Err(InvalidCsv({ record, field, line, column, message })) =>
			"record ${record.to_str()}, field ${field.to_str()} (line ${line.to_str()}, column ${column.to_str()}): ${message}"
	}
}

Stdout.line!(describe("Airplane!,1980,Robert Hays;Julie Hagerty"))?
Stdout.line!(describe("Airplane!,1980,Robert Hays\nCaddyshack,soon,Bill Murray"))?
Stdout.line!(describe("Airplane!,1980,Robert Hays,extra"))?
Output
1 movies
record 2, field 2 (line 2, column 12): expected a U64, found `soon`
record 1, field 4 (line 1, column 28): the record has 4 fields, but the parser read only 3

3.1.6. Which CSV files are accepted§

The dialect is RFC 4180 with the relaxations that Python’s csv module (in strict mode) and Rust’s csv crate also accept:

Stdout.line!(raw_records("size,6\" pipe\rcount,"))?
Stdout.line!(raw_records("bolt,\"250"))?
Stdout.line!("empty input: ${Str.inspect(CSV.parse_records(""))}")?
Output
size | 6" pipe
count | 
not valid CSV at 1:6: unterminated quoted field
empty input: Ok([])

3.1.7. Limitations§

For every function and type in the module, see the CSV API reference.

3.1.8. Performance§

Reading every field as a string runs at 120–150 MB/s on an Apple M2, from a 1.6 KB export to a 1 MiB file: on par with Rust’s csv crate, about 2.3 times slower than Go’s encoding/csv and nearly twice as fast as Python’s C csv module. Delimiters and quotes are found 16 bytes at a time with SIMD, and fields without doubled quotes are slices of the input. The parser is a byte scanner and stays linear on very wide rows and long runs of escaped quotes. Decoding typed columns adds the cost of each conversion. The whole file and every field are held in memory: about 12 MiB of peak memory for a 1 MiB file. See Performance for the method and caveats.

3.2. Read YAML configuration and frontmatter§

This chapter shows how to read a YAML configuration file or the frontmatter at the top of a Markdown post into a Roc value, pull settings out of it, and report mistakes with a line and column. It is for Roc programmers who have added parser to their app’s dependencies and know the YAML they want to read.

The parser handles the YAML that configuration files and frontmatter use in practice, and rejects the rest with an error instead of guessing. If your files use anchors, tags or several documents in one file, read what is rejected first.

The decoding examples come from docs/examples/yaml-guide.roc; the source-highlighting example comes from docs/examples/yaml-source-guide.roc. CI checks, tests and runs both against the package beside this manual. Both import parser.Yaml.

3.2.1. Decode a document into a record§

Yaml.decode reads a document straight into your own Roc type. You do not pass the type: it is inferred from how the result is used, usually from a type annotation. This configuration, held in a Roc multi-line string (each \\ starts one line of the file):

config_text =
	\\# deployment settings
	\\name: web
	\\replicas: 3
	\\debug: false
	\\ports: [80, 443]
	\\owners:
	\\  - name: Ada
	\\    email: ada@example.com
	\\  - name: Grace

is read by:

Owner : { name : Str, email : Try(Str, [Missing]) }

Config : {
	name : Str,
	replicas : U8,
	debug : Bool,
	ports : List(U16),
	owners : List(Owner),
}

## The record type tells `Yaml.decode` what to expect.
load_config : Str -> Try(Config, [InvalidYaml(Yaml.Error), MissingRequiredField(Str)])
load_config = |text| Yaml.decode(text)

summarize : Str -> Try(Str, [InvalidYaml(Yaml.Error), MissingRequiredField(Str)])
summarize = |text| {
	config = load_config(text)?
	owner_names = config.owners.map(|owner| owner.name)

	Ok("${config.name}: ${config.replicas.to_str()} replicas, owners ${Str.join_with(owner_names, " and ")}")
}

summarize(config_text) returns:

Output
web: 3 replicas, owners Ada and Grace

The rules that map YAML onto Roc types:

A value of the wrong shape or out of range fails with InvalidYaml at that value’s line and column, the same error invalid YAML gives. A required field whose key is absent fails with MissingRequiredField(name). Your code does not declare that tag: the compiler adds it to the error type of every decoder of a record with required fields, so name it in the annotation:

Stdout.line!(Str.inspect(load_config("name: web\nreplicas: 300\n")))?
Stdout.line!(Str.inspect(load_config("name: web\nreplicas: 3\ndebug: no\n")))?
Stdout.line!(Str.inspect(load_config("name: web\nreplicas: 3\ndebug: false\nports: []\n")))?
Output
Err(InvalidYaml({ column: 11, line: 2, message: "integer `300` is outside the range of U8" }))
Err(InvalidYaml({ column: 8, line: 3, message: "expected a boolean, found `no`" }))
Err(MissingRequiredField("owners"))

3.2.1.1. Other key spellings and unknown keys§

Yaml.decoder builds a decoder with other conventions. keys says how YAML spells Roc’s snake_case field names: SnakeCase (as written), KebabCase (user-id) or CamelCase (userId). unknown_keys: Reject turns a key the record does not have into an InvalidYaml error, which catches typos in hand-written configuration:

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

load : Str -> Try({ user_id : U64 }, [InvalidYaml(Yaml.Error), MissingRequiredField(Str)])
load = |text| decode_strict(text)

Build the decoder once, as a top-level constant, and call it for each document. To write your own generic function over decodable types, require a.Yaml.Parseable(errors) in its where clause, the way Yaml.decode does.

3.2.2. Explore a document as a tree§

When the shape is not known in advance, Yaml.parse_str returns a Yaml tree:

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

A mapping keeps its entries in source order. get looks up a key, at an index, and get_path follows several of them, using decimal strings such as "0" for sequence indexes. Each returns Err(Missing) when there is nothing there. as_str, as_i64, as_bool and as_list unwrap one variant or return Err(WrongType):

## Explore a document without a record type.
first_owner : Str -> Try(Str, [InvalidYaml(Yaml.Error), Missing, WrongType])
first_owner = |text| {
	config = Yaml.parse_str(text)?
	name = config.get_path(["owners", "0", "name"])?
	name.as_str()
}

For config_text, and then for "owners: []":

Output
Ok("Ada")
Err(Missing)

Empty input, or a document holding only comments, parses as Null. To combine YAML with other parsers, Yaml.parser is a Parser(Utf8.Bytes, Yaml) that reads the rest of its input; its ParseError offset is the byte offset of the error’s line and column.

3.2.3. Read Markdown frontmatter§

Frontmatter is a YAML document between two --- lines at the start of a Markdown file. Split the text at the closing --- and parse the first part. The parser accepts the opening --- (and a closing ...) as document markers, so you do not need to strip them:

## Split a Markdown file into its YAML frontmatter and body.
frontmatter : Str -> Try({ meta : Yaml, body : Str }, [NoFrontmatter, InvalidYaml(Yaml.Error)])
frontmatter = |markdown| {
	match Str.split_on(markdown, "\n---\n") {
		[header, .. as rest] if Str.starts_with(header, "---\n") =>
			Ok({ meta: Yaml.parse_str(header)?, body: Str.join_with(rest, "\n---\n") })

		_ => Err(NoFrontmatter)
	}
}

For "---\ntitle: Hello\ntags: [roc, yaml]\n---\n# Hello\n", printing Yaml.to_inspect(meta) and then the body gives:

Output
Mapping([{ key: "title", value: Text("Hello") }, { key: "tags", value: Sequence([Text("roc"), Text("yaml")]) }])
# Hello

Yaml.decode works on the same text when you know the frontmatter’s fields.

3.2.4. How plain values are typed§

In a Yaml tree, an unquoted value is resolved with the YAML 1.2 core schema. Anything that matches none of these forms is Text:

Written asBecomes
~, null, Null, NULL, or nothingNull
true, True, TRUE, false, False, FALSEBool
Decimal digits with an optional sign; unsigned 0x hexadecimal or 0o octalInt (an I64; a value outside its range is an error)
Decimal with a fraction or exponent; .inf, -.inf, .nan in the three letter casesFloat
show!("[~, null, true, FALSE, 42, -7, 0x1F, 0o17, 1.5, 6.02e23, .inf, -.Inf, .nan, 1.2.3, 1_000, 'true', 2026-10-01]")
Output
Sequence([Null, Null, Bool(True), Bool(False), Int(42), Int(-7), Int(31), Int(15), Float(1.5), Float(6.02e23), Float(inf), Float(-inf), Float(nan), Text("1.2.3"), Text("1_000"), Text("true"), Text("2026-10-01")])

The last four values show what stays text: a version number such as 1.2.3, a number with _ separators, anything in quotes, and dates (YAML 1.2 has no timestamp type). tRUE and 0X1 are text too, because the core schema allows only the spellings in the table. In a tree, quote a value whenever it must stay text; Yaml.decode reads any non-null plain scalar into a Str field as written.

Yaml.to_inspect prints a tree in Roc notation, escaping line breaks, tabs and other control characters so each value stays on one line.

3.2.5. Write multi-line text§

Use a block scalar for text that spans lines. | (literal) keeps line breaks; > (folded) joins lines with spaces and keeps a blank line as a paragraph break. A chomping indicator controls the final line break: none keeps one, - removes it, and `` keeps every trailing blank line. A digit sets the content indentation, so the text can start with spaces:

show!(
	\\literal: |
	\\  line one
	\\  line two
	\\folded: >
	\\  joined
	\\  into one line
	\\
	\\  new paragraph
	\\stripped: |-
	\\  no final newline
	\\kept: |+
	\\  trailing blank lines kept
	\\
	\\indented: |2
	\\    starts with two spaces
)
Output
Mapping([{ key: "literal", value: Text("line one\nline two\n") }, { key: "folded", value: Text("joined into one line\nnew paragraph\n") }, { key: "stripped", value: Text("no final newline") }, { key: "kept", value: Text("trailing blank lines kept\n\n") }, { key: "indented", value: Text("  starts with two spaces") }])

3.2.6. Write lists§

A sequence item may start with a mapping on the same line as its - (compact), may hold another sequence the same way, and may sit at the same indentation as its parent key (indentless):

show!(
	\\steps:
	\\- run: build
	\\  name: compile
	\\- - nested
	\\  - compact
)
Output
Mapping([{ key: "steps", value: Sequence([Mapping([{ key: "run", value: Text("build") }, { key: "name", value: Text("compile") }]), Sequence([Text("nested"), Text("compact")])]) }])

Short lists and mappings can also use flow style on one line, as in ports: [80, 443] or labels: { tier: web }.

3.2.7. Quote strings§

A single-quoted string has no escapes except '' for one quote. A double-quoted string supports every YAML 1.2 escape, including \t, \n, \xXX, \uXXXX and \UXXXXXXXX. A # inside quotes is text; outside quotes and after a space it starts a comment:

show!(
	\\single: 'it''s # not a comment'
	\\plain: text # this is a comment
)
show!(
	"double: \"tab\\there, \\u00e9, \\x41\"",
)
Output
Mapping([{ key: "single", value: Text("it's # not a comment") }, { key: "plain", value: Text("text") }])
Mapping([{ key: "double", value: Text("tab\there, é, A") }])

3.2.8. Highlight the original source§

Use Yaml.tokenize_str(text) when you need to display YAML as written. Unlike a decoded tree, its result retains whitespace, comments, quotes, line endings and punctuation. Yaml.tokenize_bytes(bytes) also accepts arbitrary bytes, including invalid UTF-8. Both return this shape:

{
    source : List(U8),
    tokens : List(Yaml.Token),
    diagnostics : List(Yaml.Diagnostic),
    status : [Complete, Recovered, Limited],
}

Each token has kind, start, end, role and scalar fields. start and end are U64 byte offsets into source, with start included and end excluded. They are byte coordinates, not character indexes: a UTF-8 é occupies two bytes and CRLF occupies two bytes. Nonempty tokens cover the entire input in order without gaps or overlap; empty input has no tokens. There is no synthetic end-of-file token. Concatenating the slices reproduces the input byte for byte, including malformed bytes.

The scanner distinguishes these token kinds:

SourceKinds
TriviaWhitespace, Comment
Scalar textPlainScalar, SingleQuotedScalar, DoubleQuotedScalar
Block scalarsBlockScalarHeader, BlockScalarContent
Block punctuationMappingValue, SequenceEntry, ExplicitKey
Flow punctuationFlowSequenceStart, FlowSequenceEnd, FlowMappingStart, FlowMappingEnd, FlowEntry
Names and stream markersAnchor, Alias, Tag, Directive, DocumentStart, DocumentEnd
RecoveryInvalid

role is Key, Value or Other. scalar is Text, Boolean, Null or Number; it describes lexical spelling without constructing a semantic value or imposing the tree parser’s integer range. Quoted and block scalar contents stay text. Plain scalars follow YAML 1.2 spelling: on is text, false is boolean, and null is null. A mapping key’s role takes precedence for highlighting, so on: and true: both get PropertyName.

Yaml.highlight_category(token) maps a token to Trivia, CommentText, PropertyName, StringText, NumericLiteral, BooleanLiteral, NullLiteral, Symbol, Name, TypeName or InvalidText. Use these categories as theme keys in either HTML or PDF output. The helper returns no rendered markup; your output writer copies the original slices and escapes or encodes them for its own output format.

This example uses CRLF and non-ASCII text, a workflow key, a URL fragment, and a shell script in a block scalar:

workflow = "# CI\r\non: [push]\r\nname: 'Check café'\r\nurl: https://example.com/manual#setup\r\ncontinue-on-error: false\r\nrun: |-\r\n  # Shell text, not a YAML comment\r\n  echo ok\r\n"

# Each piece retains bytes from the original source, including whitespace.
# Both an HTML writer and a PDF writer can consume these same pieces.
source_pieces = |tokenized| tokenized.tokens.map(
	|token| {
		category: Yaml.highlight_category(token),
		bytes: tokenized.source.sublist({ start: token.start, len: token.end - token.start }),
	},
)

copy_source = |tokenized| source_pieces(tokenized).fold([], |bytes, piece| bytes.concat(piece.bytes))

Passing each piece to an output writer keeps the same category and byte range in both renderers. copy_source is a minimal sink that discards the theme and joins the original bytes; a production writer can stream each slice directly. Inspecting a few categories:

tokenized = Yaml.tokenize_str(workflow)
Stdout.line!("preserved: ${Str.inspect(copy_source(tokenized) == workflow.to_utf8())}")?
for token in tokenized.tokens {
	bytes = tokenized.source.sublist({ start: token.start, len: token.end - token.start })
	if bytes == "on".to_utf8() or bytes == "false".to_utf8() or bytes == "https://example.com/manual#setup".to_utf8() {
		Stdout.line!("${Str.from_utf8(bytes) ?? ""}: ${Str.inspect(Yaml.highlight_category(token))}")?
	}
}
Output
preserved: True
on: PropertyName
https://example.com/manual#setup: StringText
false: BooleanLiteral

# starts a comment only in a YAML comment position. Quoted #, a URL’s #setup fragment, and the shell comment inside run: |- stay scalar content. Literal |, folded >, and chomping or indentation indicators remain in their original spelling. GitHub Actions ${{ ... }} expressions remain YAML scalar text; an application can add expression colouring as a second overlay within the returned spans.

3.2.8.1. Recovery and semantic validation§

Tokenization does not require successful decoding. It recognizes anchors, aliases, tags and stream markers even where the semantic parser rejects them. Complete means the lexical scan completed without a diagnosed problem; it does not establish that the YAML is semantically valid. Duplicate keys and unsupported decoding features still need a semantic check when you intend to load configuration.

Each diagnostic has a code and half-open start/end byte coordinates. Codes are UnclosedQuote, InvalidEscape, UnclosedCollection, UnexpectedDelimiter, InvalidIndentation, InvalidBlockHeader, InvalidCharacter, InvalidUtf8 and ResourceLimit. Recovered reports lexical problems while keeping usable tokens and all original bytes. Limited reports more than 256 simultaneously open flow collections. The scanner emits an Invalid token covering the remaining input and a ResourceLimit diagnostic, so callers can display those bytes unchanged. This lexical limit is separate from semantic decoding’s 100-level nesting limit. An unclosed quoted scalar retains the bytes through end of input as one quoted token and reports UnclosedQuote. Invalid escapes stay inside the quoted token with diagnostics. Unmatched closing flow delimiters remain punctuation tokens; unclosed collections report their opening spans through end of input. These recovery choices keep a broken example displayable; they do not repair it or supply a valid semantic value.

# Tokenization succeeds as a source operation even when decoding cannot.
broken = Yaml.tokenize_str("name: \"unfinished")

invalid_utf8 = Yaml.tokenize_bytes([0xFF, 0x0D, 0x0A])

expect copy_source(broken) == broken.source
expect !broken.diagnostics.is_empty()
expect copy_source(invalid_utf8) == [0xFF, 0x0D, 0x0A]
expect !invalid_utf8.diagnostics.is_empty()

If a renderer cannot use a token’s category, it can always copy its source slice with the default text style. Keep source as bytes when exact preservation matters: converting invalid UTF-8 to a string with replacement characters changes those bytes. A text output format may instead show a replacement glyph while retaining the original byte coordinates for reporting diagnostics.

3.2.9. What semantic decoding rejects§

The following are outside the supported subset. Each one is an InvalidYaml error, never a silently different value:

Mapping keys are kept as their source text, not resolved like values. 1 and 01 are different keys, and true is the string key "true".

3.2.10. Show where an error is§

InvalidYaml carries a Yaml.Error record: a one-based line and column and a message. Columns count bytes from the start of the line:

report : Str -> Str
report = |text| {
	match Yaml.parse_str(text) {
		Ok(value) => Yaml.to_inspect(value)
		Err(InvalidYaml({ line, column, message })) => "line ${line.to_str()}, column ${column.to_str()}: ${message}"
	}
}

Stdout.line!(report("server:\n\tport: 80"))?
Stdout.line!(report("base: &defaults\n  port: 80"))?
Stdout.line!(report("one: 1\n---\ntwo: 2"))?
Stdout.line!(report("name: a\nname: b"))?
Stdout.line!(report("list: [1,\n  2]"))?
Stdout.line!(report("1: one\n01: zero-one"))?
Output
line 2, column 1: tabs may not be used for YAML indentation
line 1, column 7: anchors, aliases, and tags are not supported by this YAML subset
line 2, column 1: multiple YAML documents are not supported
line 2, column 1: duplicate mapping key `name`
line 1, column 7: unterminated flow collection
Mapping([{ key: "1", value: Text("one") }, { key: "01", value: Text("zero-one") }])

For the full module interface, see the Yaml API reference.

3.2.11. Performance§

Parsing takes time linear in the size of the document, at about 51–55 MB/s on an Apple M2 from 4 KiB to 1 MiB: a 4 KiB configuration in under 0.1 ms and a 1 MiB one in about 20 ms. That is about as fast as yaml-rust2 (Rust), 1.6 times faster than serde_yaml, 2.6 times faster than Go’s yaml.v3 and seven times faster than PyYAML with libyaml. Nesting is limited to 100 levels. Decoding into a record builds the same document first, then walks it once. See Performance for the measurements and the comparison with other libraries.

3.3. Read XML documents§

This chapter shows how to parse an XML document into a tree, find elements, attributes and text in it, and report malformed input with a line and column. It is for Roc programmers who have added parser to their app’s dependencies and need data out of XML such as feeds, SVG files or configuration.

The parser checks that a document is well-formed according to XML 1.0 and gives you the tree after the usual XML clean-up: references replaced by their text, line endings normalised, comments dropped. It does not validate against a schema or DTD, and it rejects documents that contain a <!DOCTYPE>; see what is not supported before using it on documents from other tools.

Every example on this page comes from docs/examples/xml-guide.roc, which CI runs, and imports parser.Xml.

3.3.1. Parse a document and find elements§

Xml.parse_str takes the whole document and returns an Xml record with the document’s root element and its declaration. Each node in the tree is one of:

Node := [
    Element({ name : Str, attributes : List(Attribute), children : List(Node) }),
    Text(Str),
]

An Element holds its name, its attributes in source order, and its children. There is no query language, but Xml.Node has four helpers for the common lookups, and you can pattern match on the tree for anything else:

describe_entry : Xml.Node -> Str
describe_entry = |entry| {
	id = entry.attribute("id") ?? "?"
	titles = Str.join_with(entry.children_named("title").map(|title| title.text()), "")

	"entry ${id}: ${titles}"
}

Given this document:

feed_text =
	\\<?xml version="1.0" encoding="UTF-8"?>
	\\<feed lang="en">
	\\  <!-- newest first -->
	\\  <entry id="2"><title>Fish &amp; Chips</title></entry>
	\\  <entry id="1"><title><![CDATA[<Hello>]]> &#x1F600;</title></entry>
	\\</feed>

parse it and describe each entry element under the root:

match Xml.parse_str(text) {
	Ok(xml) =>
		for entry in xml.root.children_named("entry") {
			# Keep the displayed output portable across documentation fonts.
			Stdout.line!(describe_entry(entry).replace_each("\u(1F600)", "\\u(1F600)"))?
		}

	Err(InvalidXml(problem)) => Stdout.line!("invalid XML: ${problem.message}")?
}
Output
entry 2: Fish & Chips
entry 1: <Hello> \u(1F600)

The whitespace between elements is kept as Text nodes; children_named skips them because it only returns elements. &amp;, the CDATA section and the character reference &#x1F600; have already been turned into ordinary text.

3.3.2. What the tree contains§

The tree is what an XML processor reports, not the literal source:

This document has every one of these in a single element:

print_tree!("<p class='a\tb'>x &lt; y &#65;&#x42;\r\n<![CDATA[<raw>]]><!-- gone -->z<br/></p>")?

Printing the root with Str.inspect gives:

Output
Element({ attributes: [{ name: "class", value: "a b" }], children: [Text("x < y AB
<raw>z"), Element({ attributes: [], children: [], name: "br" })], name: "p" })

3.3.3. Report malformed input§

When a document is not well-formed, Xml.parse_str returns InvalidXml({ line, column, message }). Lines and columns are one-based, and columns count UTF-8 bytes, so a column after non-ASCII text is larger than the number of characters a user sees.

check : Str -> Str
check = |text| {
	match Xml.parse_str(text) {
		Ok(_) => "well-formed"
		Err(InvalidXml({ line, column, message })) => "${line.to_str()}:${column.to_str()}: ${message}"
	}
}

Stdout.line!(check("<a>\n  <b>\n</a>"))?
Stdout.line!(check("<a>&nbsp;</a>"))?
Stdout.line!(check("<a x='1' x='2'/>"))?
Stdout.line!(check("<a/><b/>"))?
Stdout.line!(check("<!DOCTYPE html><html/>"))?
Output
3:1: end tag </a> does not match start tag <b>
1:4: undeclared entity &nbsp;
1:10: duplicate attribute x
1:5: unexpected content after the root element
1:1: document type declarations are not supported

If you are combining XML with other parsers, Xml.parser is the same parser as a Parser(Utf8.Bytes, Xml). It leaves any input after the document for the next parser. Its failures are ParseError({ message, offset }), with the same message as Xml.parse_str and the byte offset of the problem.

3.3.4. What is not supported§

These limits are by design:

Document type declarations

A <!DOCTYPE ...> is rejected. As a result only the five predefined entities exist, and any other reference, such as &nbsp;, is an error. Replace HTML entities with character references (&#160;) before parsing.

Namespaces

Names are kept exactly as written. svg:path is an element named "svg:path", and xmlns declarations are ordinary attributes; prefixes are not resolved to URIs.

Declared encodings

The input is a Roc Str, which is already UTF-8. An encoding="..." in the declaration is reported in declaration but not used. Convert other encodings to UTF-8 before parsing.

Validation

Only well-formedness is checked. Required elements, attribute types and schemas are not.

For the full module interface, see the Xml API reference.

3.3.5. Performance§

Xml.parse_str builds the tree at about 120–135 MB/s on an Apple M2, and the rate stays flat from 4 KiB to 1 MiB documents: the parser is linear in the input size. On the generated documents that is about 2.2 times as fast as Go’s encoding/xml, and 1.4–2 times slower than Rust’s quick-xml and roxmltree. Character data and attribute values are found 16 bytes at a time with SIMD, the input is validated as UTF-8 once, and names, text and attribute values with nothing to unescape are slices of the input string, so they keep the input alive. Elements are parsed with an explicit stack, so 5,000 levels of nesting are safe, and duplicate-attribute checks are linear: an element with 5,000 attributes parses 19 times faster than with the Rust libraries. See Performance for the measurements and caveats.

3.4. Read and write JSON§

This chapter shows how to read a JSON document into a value whose shape you do not have to know in advance, walk it, report mistakes with a line and column, choose how strict to be, and write a value back out. It is for Roc programmers who have added parser to their app’s dependencies and need to handle JSON that a record type cannot describe: software bills of materials, JSON Schema files, configuration with comments, or data that must pass through unchanged.

By default the parser is strict: it accepts only the JSON of RFC 8259, and also rejects repeated keys and nesting deeper than 128 levels. Options relax it for common variants, such as comments, trailing commas and a byte order mark, and add checks, such as size limits and I-JSON numbers. It reads complete documents held in memory; see what is not supported.

Every example on this page comes from docs/examples/json-guide.roc, which CI checks and runs, and imports parser.JsonValue.

3.4.1. The builtin Json or JsonValue§

Roc auto-imports a builtin module named Json. It decodes a document straight into a record, list or other type that you name in an annotation, in the way Choosing how to read your input describes. Use it when you know the shape of the document and a Roc type can hold all of it.

Use JsonValue when a record type cannot describe the document, or would lose part of it:

The module is called JsonValue because the builtin already has the name Json: import parser.Json would leave the alias unbound. Its error tag is InvalidJsonValue for the same reason. The builtin reports invalid input as InvalidJson with a Str, and one tag cannot carry two payload types, so the different name lets one function use ? on both modules.

3.4.2. Parse a document and walk it§

JsonValue.parse_str reads a whole document and returns a JsonValue, or InvalidJsonValue with the position of the first problem. A JsonValue is one of:

JsonValue := [
    Null,
    Bool(Bool),
    Number(Str),
    String(Str),
    Array(List(JsonValue)),
    Object(List({ key : Str, value : JsonValue })),
]

An Object keeps its members in source order. A Number holds the number’s text exactly as written; Numbers keep their text explains why and how to convert it. JsonValue has methods for the common lookups:

This CycloneDX bill of materials gives each component’s licence either as an SPDX id or as an expression, and names its components with a bom-ref key that no Roc record field could have:

sbom_text =
	\\{
	\\  "bomFormat": "CycloneDX",
	\\  "specVersion": "1.6",
	\\  "components": [
	\\    {
	\\      "bom-ref": "pkg:roc/parser@2.1.0",
	\\      "name": "roc-parser",
	\\      "version": "2.1.0",
	\\      "licenses": [{ "license": { "id": "UPL-1.0" } }]
	\\    },
	\\    {
	\\      "bom-ref": "pkg:roc/unicode@4.3.0",
	\\      "name": "unicode",
	\\      "version": "4.3.0",
	\\      "licenses": [{ "expression": "UPL-1.0 OR MIT" }]
	\\    }
	\\  ]
	\\}

These functions list the components with their licences, trying both shapes:

## A component's licence: an SPDX id, or an SPDX expression.
licence : JsonValue -> Str
licence = |component| {
	match component.get_path(["licenses", "0", "license", "id"]) {
		Ok(String(id)) => id
		_ =>
			match component.get_path(["licenses", "0", "expression"]) {
				Ok(String(expression)) => expression
				_ => "no licence"
			}
	}
}

## One line per component of a CycloneDX document.
components : Str -> Try(List(Str), [InvalidJsonValue(JsonValue.Error), Missing, WrongType])
components = |text| {
	bom = JsonValue.parse_str(text)?
	found = bom.get("components")?
	list = found.as_list()?
	Ok(
		list.map(
			|component| {
				name = component.get("name") ?? Null
				version = component.get("version") ?? Null
				"${name.as_str() ?? "?"} ${version.as_str() ?? "?"}: ${licence(component)}"
			},
		),
	)
}

print_components!(sbom_text)?
Stdout.line!(Str.inspect(JsonValue.parse_str(sbom_text).map_ok(JsonValue.keys)))?
Output
roc-parser 2.1.0: UPL-1.0
unicode 4.3.0: UPL-1.0 OR MIT
Ok(["bomFormat", "specVersion", "components"])

get_path uses each key as it is written, so a key that contains /, . or ~ needs no escaping. A segment applied to an array must be a decimal index without a sign or leading zeros, as in a JSON Pointer.

For anything the methods do not cover, match on the variants. This function collects every string in a document with the keys and indexes that lead to it:

## Every string in a value, with the keys and indexes that lead to it.
strings : JsonValue, Str -> List(Str)
strings = |value, path| {
	match value {
		String(text) => ["${path} = ${text}"]
		Array(items) => {
			var $found = []
			var $index = 0.U64
			for item in items {
				$found = $found.concat(strings(item, "${path}/${$index.to_str()}"))
				$index = $index + 1
			}
			$found
		}

		Object(members) => {
			var $found = []
			for member in members {
				$found = $found.concat(strings(member.value, "${path}/${member.key}"))
			}
			$found
		}

		Null | Bool(_) | Number(_) => []
	}
}

Keeping only the lines that mention bom-ref prints:

Output
/components/0/bom-ref = pkg:roc/parser@2.1.0
/components/1/bom-ref = pkg:roc/unicode@4.3.0

3.4.3. Show where an error is§

InvalidJsonValue carries a JsonValue.Error record: a one-based line and column and a message. Columns count bytes from the start of the line, and a line ends at LF, CR or CRLF. The position is the start of the problem: the first character of a bad number or literal, the comma before a closing bracket, the slash of a comment, or the opening quote of a repeated key.

report : Str -> Str
report = |text| {
	match JsonValue.parse_str(text) {
		Ok(_) => "valid"
		Err(InvalidJsonValue({ line, column, message })) => "${line.to_str()}:${column.to_str()}: ${message}"
	}
}

Stdout.line!(report("{\n  \"name\": \"web\",\n  \"port\": 08080\n}"))?
Stdout.line!(report("[\"café\", tru]"))?
Stdout.line!(report("{\"name\": 'web'}"))?
Stdout.line!(report("[1, 2,]"))?
Stdout.line!(report("// settings\n{}"))?
Stdout.line!(report("{\"a\": 1} {\"b\": 2}"))?
Stdout.line!(report("\"\\ud800\""))?
Output
3:11: leading zeros are not allowed in numbers
1:11: invalid literal: expected true, false or null
1:10: expected a JSON value
1:6: trailing comma is not allowed
1:1: comments are not allowed
1:10: unexpected content after the JSON value
1:2: unpaired surrogate in \u escape

The second error is at column 11 although tru is the tenth character, because é is two bytes in UTF-8. JSON has no single-quoted strings and no comments, and a document holds exactly one value, so text after the first value is an error rather than a second document; Combine with other parsers shows how to read several values. The last input is a \u escape for half of a UTF-16 surrogate pair, with no other half. RFC 8259 leaves such escapes to the implementation. A Roc Str cannot hold one, so JsonValue rejects it unless you ask for U+FFFD instead (see Choose how strict to be).

3.4.4. Numbers keep their text§

JSON does not limit the size or precision of numbers, and parsers disagree about what 18446744073709551616 or 0.1000000000000000055511151231257827 means: many turn every number into a 64-bit float and lose digits. JsonValue does not decide for you. A Number holds the text from the input, so a number read and written back keeps every digit, its sign and the spelling of its exponent. Convert it when you need arithmetic:

## How each accessor reads a number's text.
convert : JsonValue -> Str
convert = |value| {
	text =
		match value {
			Number(lexeme) => lexeme
			_ => "not a number"
		}
	"${text}: as_i64 ${Str.inspect(value.as_i64())}, as_u64 ${Str.inspect(value.as_u64())}, as_dec ${Str.inspect(value.as_dec())}"
}

for text in ["42", "-0", "1.50", "1e3", "18446744073709551616", "1e400"] {
	Stdout.line!(convert(JsonValue.parse_str(text) ?? Null))?
}
Stdout.line!(Str.inspect(JsonValue.parse_str("1.50") == JsonValue.parse_str("1.5")))?
Stdout.line!(Str.inspect(F64.from_str("1.50")))?
Output
42: as_i64 Ok(42), as_u64 Ok(42), as_dec Ok(42.0)
-0: as_i64 Ok(0), as_u64 Ok(0), as_dec Ok(0.0)
1.50: as_i64 Err(WrongType), as_u64 Err(WrongType), as_dec Ok(1.5)
1e3: as_i64 Err(WrongType), as_u64 Err(WrongType), as_dec Ok(1000.0)
18446744073709551616: as_i64 Err(OutOfRange), as_u64 Err(OutOfRange), as_dec Ok(18446744073709551616.0)
1e400: as_i64 Err(WrongType), as_u64 Err(WrongType), as_dec Err(OutOfRange)
False
Ok(1.5)

Because values compare by their text, 1.50 and 1.5 are different JsonValue values, as the last line but one shows. Convert both with as_dec to compare them as numbers.

3.4.5. Repeated keys§

RFC 8259 says that the names within an object should be unique, and leaves open what a repeated name means. Readers differ: most keep the last value, some keep the first, and some reject the document. Two programs can then read different data from the same file, so by default JsonValue rejects a repeated key. Keys are compared after their escapes are decoded, so "extension" and "\u0065xtension" are the same key.

The duplicates option chooses what a repeated key means:

Reject

The default. Any repeated key is an error, reported at the repeated key.

CollapseIdentical

Keep one member when every copy has an equal value, and report an error when the values differ. Values are compared as JsonValue values: numbers by their text and objects member by member in order, so 1 and 1.0, or the same members in another order, differ.

KeepFirst

Keep the first member and ignore later copies.

KeepLast

Keep the first member’s position with the last copy’s value, as JavaScript’s JSON.parse and Python’s json.loads do.

## Read `text` with each duplicate mode, and write what each one kept.
modes : Str -> List(Str)
modes = |text| {
	options : List((Str, JsonValue.Options))
	options = [
		("Reject", {}),
		("CollapseIdentical", { duplicates: CollapseIdentical }),
		("KeepFirst", { duplicates: KeepFirst }),
		("KeepLast", { duplicates: KeepLast }),
	]
	options.map(
		|(name, chosen)| {
			match JsonValue.parse_with(text, chosen) {
				Ok(value) => "${name}: ${JsonValue.to_str(value) ?? "?"}"
				Err(InvalidJsonValue({ line, column, message })) => "${name}: ${line.to_str()}:${column.to_str()}: ${message}"
			}
		},
	)
}

print_lines!(modes("{\"id\": 1, \"name\": \"web\", \"id\": 2}"))?
print_lines!(modes("{\"extension\": {\"a\": 1}, \"\\u0065xtension\": {\"a\": 1}}"))?
Output
Reject: 1:26: duplicate key "id"
CollapseIdentical: 1:26: duplicate key "id" with a different value
KeepFirst: {"id":1,"name":"web"}
KeepLast: {"id":2,"name":"web"}
Reject: 1:25: duplicate key "extension"
CollapseIdentical: {"extension":{"a":1}}
KeepFirst: {"extension":{"a":1}}
KeepLast: {"extension":{"a":1}}

CollapseIdentical exists for documents that repeat a key by mistake without changing its meaning. The published SPDX 3.0.1 JSON schema is one: it defines extension and prop_Element_extension twice in the same object, with equal values each time. Strict parsing rejects it; CollapseIdentical reads it and would still report a copy with a different value. The conformance review reads the pinned schema this way (see JSON).

3.4.6. Choose how strict to be§

JsonValue.parse_with(text, options) takes a JsonValue.Options record whose fields all have defaults, so you pass only the fields you change. parse_str(text) is parse_with(text, {}): strict RFC 8259, no repeated keys, and at most 128 levels of nesting. A limit or check that fails is reported as an InvalidJsonValue error at the offending value, like any other error.

Table 1. Limits
FieldDefaultEffect
max_bytesno limitThe most bytes in the supplied input, including surrounding whitespace and any BOM. Checked before constructing a value. The parser combinator applies this limit to its entire supplied buffer, including any unconsumed suffix.
max_depth128The most arrays and objects nested inside each other. A document that is a single number or string has depth 0.
max_itemsno limitThe most items in one array or members in one object, counted as written, before repeated keys are handled.
max_string_bytesno limitThe most UTF-8 bytes in one string or key, after its escapes are decoded.
max_number_digitsno limitThe most characters in one number, counting the sign, the decimal point and the exponent.
Table 2. Relaxations
FieldDefaultEffect
commentsFalseAccept // line comments and /* */ block comments wherever whitespace may appear. Comments are not kept.
trailing_commasFalseAccept a comma after the last item of an array or the last member of an object.
bomFalseSkip a byte order mark (U+FEFF) at the very start of the document.
non_finiteFalseAccept NaN, Infinity and -Infinity, kept as Number text.
control_charactersFalseAccept unescaped characters U+0000 to U+001F, such as a tab, inside strings and keys.
lone_surrogatesRejectWith Replace, a \u escape for half of a surrogate pair becomes U+FFFD instead of an error. A complete pair is always decoded.
Table 3. Checks
FieldDefaultEffect
duplicatesRejectWhat a repeated key means; see Repeated keys.
top_levelAnyObjectOrArray or Object requires the document to be that shape.
interoperable_numbersFalseRefuse numbers that an IEEE 754 double cannot hold, as I-JSON (RFC 7493) requires: integers whose magnitude exceeds 2^53 - 1, more than 17 significant digits, and values that overflow or that underflow to zero.

The parser keeps its nesting on an explicit stack, so a higher max_depth cannot overflow the call stack while parsing. Other operations do recurse over a value: comparing values with == or CollapseIdentical, hashing, and Str.inspect. The writer refuses more than 128 levels. Raise the limit only as far as your documents need.

3.4.6.1. Read JSON with comments§

Configuration files such as tsconfig.json and editor settings use JSON with comments and trailing commas, often called JSONC:

tsconfig_text =
	\\{
	\\  // Compiler options for the example project.
	\\  "compilerOptions": {
	\\    "target": "ES2022", /* modern output */
	\\    "strict": true,
	\\  },
	\\  "include": ["src/**/*.ts",],
	\\}

## JSON with comments, as in `tsconfig.json` and editor settings.
read_jsonc : Str -> Try(JsonValue, [InvalidJsonValue(JsonValue.Error)])
read_jsonc = |text| JsonValue.parse_with(text, { comments: True, trailing_commas: True })

Reading tsconfig_text with read_jsonc, and then strictly with parse_str, and writing the first result with to_str:

Output
{"compilerOptions":{"target":"ES2022","strict":true},"include":["src/**/*.ts"]}
2:3: comments are not allowed

/* inside a string, as in "src/**/*.ts", is text, not a comment. An unterminated block comment is reported where it opens. These two options do not make the parser read JSON5; see What is not supported.

3.4.6.2. Read files with a byte order mark§

Some Windows editors start a UTF-8 file with a byte order mark. parse_str rejects it, at line 1, column 1. bom: True skips it, and only at the start of the document. This reader also requires the document to be an object, as a configuration file must be:

## A configuration file that may start with a byte order mark, as files
## saved by some Windows editors do, and must hold an object.
read_config : Str -> Try(JsonValue, [InvalidJsonValue(JsonValue.Error)])
read_config = |text| JsonValue.parse_with(text, { bom: True, top_level: Object })

show!(read_config("\u(FEFF){\"port\": 8080}"))?
show!(JsonValue.parse_str("\u(FEFF){\"port\": 8080}"))?
show!(read_config("[8080]"))?
Output
{"port":8080}
1:1: byte order mark is not allowed
1:1: top-level value must be an object

3.4.6.3. Read NaN and Infinity§

Python’s json.dumps writes NaN, Infinity and -Infinity for floats that JSON cannot express, unless it is told not to. non_finite: True reads them as Number text. They are not numbers that as_dec, as_i64 or as_u64 can convert, and the writer refuses them, so what to_str writes is always strict JSON:

## Read metrics written by Python's `json.dumps`, which writes `NaN` and
## `Infinity` for floats that JSON cannot express.
print_loss! : Str => Try({}, _)
print_loss! = |text| {
	match JsonValue.parse_with(text, { non_finite: True }) {
		Ok(metrics) => {
			loss = metrics.get("loss") ?? Null
			Stdout.line!(Str.inspect(loss))?
			Stdout.line!(Str.inspect(loss.as_dec()))?
			Stdout.line!(Str.inspect(JsonValue.to_str(metrics)))?
		}

		Err(_) => Stdout.line!("invalid JSON")?
	}
	Ok({})
}

For {"loss": NaN, "best": -Infinity} this prints:

Output
Number("NaN")
Err(OutOfRange)
Err(InvalidNumber("NaN"))

3.4.6.4. Limit untrusted input§

A document from the network can be built to make a reader do too much work or hold too much memory. Set limits that suit your documents, require the top-level shape you expect, and keep repeated keys rejected:

## Options for documents from an untrusted source.
untrusted : JsonValue.Options
untrusted = {
	max_depth: 32,
	max_items: 1000,
	max_string_bytes: 4096,
	max_number_digits: 40,
	top_level: Object,
	interoperable_numbers: True,
}

show!(JsonValue.parse_with("{\"ids\": [1, 2, 3]}", untrusted))?
show!(JsonValue.parse_with("{\"id\": 9007199254740993}", untrusted))?
show!(JsonValue.parse_with("{\"a\": \"${Str.repeat("x", 5000)}\"}", untrusted))?
show!(JsonValue.parse_with("[1, 2, 3]", untrusted))?
Output
{"ids":[1,2,3]}
1:8: number 9007199254740993 is not interoperable: it is an integer beyond 2^53-1 in magnitude
1:7: string exceeds the limit of 4096 bytes
1:1: top-level value must be an object

9007199254740993 is a valid JSON number, but a JavaScript program would read it as 9007199254740992; interoperable_numbers refuses it so that every reader agrees. The options do not limit the size of the whole document: check Str.count_utf8_bytes before parsing if you need to.

3.4.7. Write JSON§

JsonValue.to_str writes a value as compact JSON, and JsonValue.to_str_with takes JsonValue.WriteOptions:

Set max_bytes in WriteOptions to bound the exact UTF-8 output size, including escaped strings, keys and indentation. It defaults to no limit. The writer checks the size before allocating its output and returns TooLarge(max_bytes) if the budget would be exceeded. It adds no final newline: reserve one byte from your budget if the calling protocol appends one.

expect JsonValue.parse_with("null", { max_bytes: 4 }) == Ok(Null)
expect JsonValue.to_str_with(String("é"), { max_bytes: 4 }) == Ok("\"é\"")
expect JsonValue.to_str_with(String("é"), { max_bytes: 3 }) == Err(TooLarge(3))

For example, a downstream SBOM tool can use a 16 MiB budget for both parsing and writing while keeping its own depth and component limits. Number text is preserved, so large identifiers and precise decimal values do not pass through floating point.

Numbers are written as their text. In strings, only the quote, the backslash and control characters are escaped: \b, \f, \n, \r and \t for those characters and \u00XX (lowercase) for the rest. Other characters, including / and non-ASCII text, are written as they are. The output is byte for byte what Python’s json.dumps(value, ensure_ascii=False, sort_keys=..., indent=...) writes, with the separators (",", ":") when compact and (",", ": ") when indented, for the same number text; the conformance review checks this.

release : JsonValue
release = Object([
	{ key: "name", value: String("roc-parser \"JSON\" café") },
	{ key: "version", value: String("2.2.0") },
	{ key: "sizes", value: Array([Number("1.50"), Number("2E3")]) },
	{ key: "draft", value: Bool(False) },
	{ key: "assets", value: Object([]) },
])

Stdout.line!(JsonValue.to_str(release) ?? "not writable")?
Stdout.line!(JsonValue.to_str_with(release, { indent: 2, sort_keys: True }) ?? "not writable")?
Stdout.line!(Str.inspect(JsonValue.to_str(Array([Number("0x10")]))))?
Stdout.line!(Str.inspect(JsonValue.to_str(Object([{ key: "a", value: Null }, { key: "a", value: Null }]))))?
Output
{"name":"roc-parser \"JSON\" café","version":"2.2.0","sizes":[1.50,2E3],"draft":false,"assets":{}}
{
  "assets": {},
  "draft": false,
  "name": "roc-parser \"JSON\" café",
  "sizes": [
    1.50,
    2E3
  ],
  "version": "2.2.0"
}
Err(InvalidNumber("0x10"))
Err(DuplicateKey("a"))

A value built in your code can hold something that JSON cannot express, and the writer refuses it rather than write text that would read back as something else:

InvalidNumber(text)

A Number whose text is not a JSON number, such as 0x10, +1, 1. or text with spaces around it, including NaN and Infinity.

DuplicateKey(key)

A key repeated in one object.

TooDeep

More than 128 levels of nesting.

TooLarge(max_bytes)

The serialized output would exceed the byte budget.

So what the writer returns always reads back with parse_str as the value you wrote, or, with sort_keys, as its sorted copy. It does not keep the input’s layout: comments, whitespace and the spelling of escapes are gone, so "\u00e9" is written "é".

3.4.8. Check JSON during compilation§

Annotate a string literal with the JsonValue type, and the compiler calls JsonValue.from_quote on it while compiling your program. The literal must be strict JSON; anything else is a compiler error, and the constant needs no parsing at run time:

lock : JsonValue
lock =
	\\{"name": "app",
	\\ "checksum": 340282366920938463463374607431768211455,
	\\ "greeting": "caf\\u00e9 \\ud83d\\ude00"}

In a Roc string literal a backslash starts a Roc escape, in multi-line strings too, so write each backslash of a JSON escape twice: JSON’s \u00e9 is \\u00e9, and \n is \\n. A single \u00e9 is a compiler error, because Roc’s own escape is written \u(E9); a single \n puts a real line break inside the JSON string, which strict JSON rejects.

For a file, import its text as a Str and require a checked conversion in a top-level declaration, as Parse and validate during compilation describes:

import "assets/json-guide-settings.jsonc" as settings_text : Str

settings : JsonValue
settings = Literal.require(
	JsonValue.validate({
		source: settings_text,
		name: "json-guide-settings.jsonc",
		options: { comments: True, trailing_commas: True },
	}),
)

JsonValue.validate takes source, a name used in errors (default "<literal>"), and options, the same JsonValue.Options that parse_with takes (default {}). JSON has no separate lint checks: its extra checks are these options. There is no validate_as; to decode JSON into a record, use the builtin Json.

Stdout.line!(Str.inspect(lock.get("checksum")))?
# Display the non-BMP scalar as an escape while keeping its decoded value.
Stdout.line!(Str.inspect(lock.get("greeting")).replace_each("\u(1F600)", "\\u(1F600)"))?
Stdout.line!(Str.inspect(settings.get_path(["files.exclude", "**/.roc-parser-tmp"])))?
Stdout.line!(Str.inspect(JsonValue.validate({ source: "{\"a\": [1,]}", name: "list.json" })))?
Output
Ok(Number("340282366920938463463374607431768211455"))
Ok(String("café \u(1F600)"))
Ok(Bool(True))
Err(BadQuotedBytes("list.json:1:9: JSON: trailing comma is not allowed"))

The last line shows the message a failed check gives, which the compiler reports with the name, line and column.

3.4.9. Combine with other parsers§

JsonValue.parser is a Parser(Utf8.Bytes, JsonValue) for use with the combinators. It reads the whitespace before a value, one strict JSON value, and the whitespace after it, and leaves the rest of the input for the next parser. JsonValue.parser_with(options) takes the same options as parse_with. Failures are ParseError({ message, offset }), with the same message as parse_str and the byte offset of the problem.

JSON Lines (newline-delimited JSON) logs hold one value per line, which Parser.many reads:

## Every value of a JSON Lines (newline-delimited JSON) log.
json_lines : Str -> Try(List(JsonValue), [ParseError({ message : Str, offset : U64 })])
json_lines = |text| Utf8.parse_str(Parser.many(JsonValue.parser), text)

Stdout.line!(Str.inspect(json_lines("{\"event\": \"start\"}\n{\"event\": \"stop\"}\n")))?
Stdout.line!(Str.inspect(json_lines("{\"event\": \"start\"}\n{\"event\": }\n")))?
Output
Ok([Object([{ key: "event", value: String("start") }]), Object([{ key: "event", value: String("stop") }])])
Err(ParseError({ message: "expected a JSON value", offset: 29 }))

Because each value takes the whitespace after it, this reader also accepts several values on one line; it does not check that each value has a line of its own.

3.4.10. What is not supported§

These limits are by design:

Streaming

The parser reads a complete document held in memory and returns a tree; there is no event or token interface. Numbers, and strings without escapes, share the input’s memory instead of being copied, so a value keeps the whole input alive while any part of it is in use.

JSON5

Only the relaxations listed in Choose how strict to be are available. Single-quoted strings, unquoted keys, hexadecimal numbers, leading or trailing decimal points, a leading ``, and line breaks escaped inside strings are always errors.

Type-directed decoding

There is no JsonValue.decode or validate_as. To decode JSON into your own record type, use the builtin Json.

Other encodings

The input is UTF-8. JsonValue.parser, which takes bytes, rejects a value that contains bytes that are not UTF-8. Convert UTF-16 and UTF-32 documents first.

Queries and schemas

get_path takes a list of keys and indexes, not a JSON Pointer string, and there is no JSONPath or JSON Schema validation.

Preserved layout

Comments, whitespace and the spelling of escapes are dropped, and the writer has no option to escape non-ASCII text.

For the full module interface, see the JsonValue API reference.

3.4.11. Performance§

The parser scans the input bytes once, keeping its nesting on an explicit stack as the XML parser does, so deep nesting cannot overflow the call stack. Repeated keys are found by comparing keys in objects of up to 16 members and with a hash table in larger ones. The module’s tests parse an object with 20,000 members and an array of 100,000 numbers, and the fuzz targets run with a time limit per input, to catch quadratic behaviour. Number text, and strings without escapes, are slices of the input string rather than copies. The benchmark harness compares JsonValue.parse_str with serde_json, Go’s encoding/json and Python’s json; see Benchmarks and Compare equivalent work.

3.5. Markdown§

This chapter shows how to turn Markdown text into a tree of Roc values and how to walk that tree to produce your own output, such as HTML or a table of contents. It is a set of how-to guides for readers who already know basic Roc and have added roc-parser to an app. By the end you can parse a document, read its blocks and inline content, pull out YAML frontmatter, and write a renderer.

The Markdown module follows CommonMark 0.31.2 plus the GitHub Flavored Markdown (GFM) extensions for tables, task list items, strikethrough and extended autolinks. It also reads a leading --- frontmatter block.

3.5.1. Parse a document into blocks§

Parse a whole document with Markdown.parse_str. It returns a List(Markdown): one value per top-level block, in document order.

Markdown has no syntax errors. Every input is a valid document, so Markdown.parse_str returns the blocks directly rather than a Try; text that looks like broken syntax becomes ordinary paragraph text. To use the document parser inside your own combinators, Markdown.parser is the same parser as a Parser(Utf8.Bytes, List(Markdown)).

The following app parses a document that uses each kind of block and prints each block with Str.inspect, which shows what the parser produced.

source =
	\\# Shopping
	\\
	\\Buy these *today*:
	\\
	\\- [x] apples
	\\- [ ] pears
	\\
	\\> Quoted
	\\
	\\```roc
	\\main = 1
	\\```
	\\
	\\| Item | Qty |
	\\| :--- | --: |
	\\| pear | 2 |
	\\
	\\***
	\\
	\\<div>raw</div>

main! = |_args| {
	blocks = Markdown.parse_str(source)
	for block in blocks {
		Stdout.line!(Str.inspect(block))?
	}
	loose_list!()
}

A list in which no items are separated by blank lines is tight; otherwise it is loose. The loose field records this, which matters when you render HTML: a tight list normally shows its item paragraphs without <p> tags. Ordered lists keep their starting number:

loose_list! = || {
	blocks = Markdown.parse_str("3. first\n\n4. second\n")
	for block in blocks {
		Stdout.line!(Str.inspect(block))?
	}
	Ok({})
}

Output:

Heading({ level: One, content: [Text("Shopping")] })
Paragraph([Text("Buy these "), Emphasis([Text("today")]), Text(":")])
ListBlock({ kind: Unordered, loose: False, items: [{ blocks: [Paragraph([Text("apples")])], task: Checked }, { blocks: [Paragraph([Text("pears")])], task: Unchecked }] })
Blockquote([Paragraph([Text("Quoted")])])
Code({ info: "roc", pre: "main = 1
" })
Table({ header: [[Text("Item")], [Text("Qty")]], align: [Left, Right], rows: [[[Text("pear")], [Text("2")]]] })
ThematicBreak
HtmlBlock("<div>raw</div>
")
ListBlock({ kind: Ordered({ start: 3 }), loose: True, items: [{ blocks: [Paragraph([Text("first")])], task: NoTask }, { blocks: [Paragraph([Text("second")])], task: NoTask }] })

The block variants are:

VariantContents
Heading({ level, content })An ATX (#) or Setext (underlined) heading. level is One to Six; level.to_str() gives "1" to "6".
Paragraph(inlines)A paragraph’s inline content.
Blockquote(blocks)The blocks inside a > quote.
ListBlock({ kind, loose, items })kind is Unordered or Ordered({ start }). Each item has blocks and a task of NoTask, Unchecked ([ ]) or Checked ([x] or [X]). Item text is always wrapped in Paragraph, even in a tight list.
Code({ info, pre })A fenced or indented code block. info is the info string after the opening fence (empty for indented code); pre is the literal content, ending in a newline.
ThematicBreakA ***, --- or ___ line.
Table({ header, align, rows })A GFM table. Each cell is a List(Inline). align holds one Default, Left, Center or Right per column.
HtmlBlock(raw)Raw HTML, including its trailing newline, exactly as written.
Frontmatter(raw)The raw text of a leading frontmatter block; only ever the first block. See Read frontmatter.

Block quotes and lists nest at most 1,000 levels deep. Deeper > and list markers are read as paragraph text. Emphasis, strikethrough, links and images also nest at most 1,000 levels deep (counted separately); the delimiters and brackets of deeper ones are read as text. Code that walks the tree recursively therefore cannot run out of stack on hostile input.

The syntax tree types support == and hashing, so blocks and inlines can be Dict keys and Set elements.

Link reference definitions ([label]: /url) do not appear in the tree. The parser resolves them into the links that use them.

3.5.2. Parse inline content§

Inline content is the text inside a paragraph, heading or table cell. When you already have a single line or fragment, such as a title from a database, parse it directly with Markdown.parse_inlines, which returns a List(Inline). Markdown.inline_parser is the same parser for use in combinators.

show_inlines! = |text| {
	inlines = Markdown.parse_inlines(text)
	for inline in inlines {
		Stdout.line!(Str.inspect(inline))?
	}
	Ok({})
}

show_inlines!("*em* **strong** ~~gone~~ `x + 1`")?
show_inlines!("[Roc](https://roc-lang.org \"Home\") and ![a cat](cat.png)")?
show_inlines!("<https://example.com> www.example.com me@example.com")?
show_inlines!("one\ntwo  \nthree")?

Output:

Emphasis([Text("em")])
Text(" ")
Strong([Text("strong")])
Text(" ")
Strikethrough([Text("gone")])
Text(" ")
InlineCode("x + 1")
Link({ label: [Text("Roc")], target: { href: "https://roc-lang.org", title: Ok("Home") } })
Text(" and ")
Image({ alt: [Text("a cat")], target: { href: "cat.png", title: Err(Missing) } })
Link({ label: [Text("https://example.com")], target: { href: "https://example.com", title: Err(Missing) } })
Text(" ")
Link({ label: [Text("www.example.com")], target: { href: "http://www.example.com", title: Err(Missing) } })
Text(" ")
Link({ label: [Text("me@example.com")], target: { href: "mailto:me@example.com", title: Err(Missing) } })
Text("one
two")
HardBreak
Text("three")

The output shows these rules:

A reference link such as [the guide][guide] points to a definition elsewhere in the document. Markdown.parse_inlines sees only the fragment you give it, so it cannot resolve the reference and leaves the brackets as text. Parse the whole document with Markdown.parse_str instead:

document =
	\\See [the guide][guide] and ![logo][].
	\\
	\\[guide]: https://example.com/guide "The guide"
	\\[logo]: /logo.png

blocks = Markdown.parse_str(document)
for block in blocks {
	Stdout.line!(Str.inspect(block))?
}

Output:

Paragraph([Text("See "), Link({ label: [Text("the guide")], target: { href: "https://example.com/guide", title: Ok("The guide") } }), Text(" and "), Image({ alt: [Text("logo")], target: { href: "/logo.png", title: Err(Missing) } }), Text(".")])

Reference labels match case-insensitively using Unicode case folding, so [Straße] matches a definition labelled [STRASSE].

3.5.3. Read frontmatter§

Many static-site tools put metadata at the top of a Markdown file between two --- lines. When the document’s first line is exactly --- and a later line is exactly ---, Markdown.parse_str returns the lines between them as the first block, Frontmatter(raw), and parses the rest as Markdown. Markdown.frontmatter(blocks) returns that text, or Err(Missing) when the document has none. The parser does not interpret the text. Pass it to the YAML parser (see Read YAML configuration and frontmatter) to read the fields:

post =
	\\---
	\\title: Hello
	\\draft: false
	\\---
	\\# Hello
	\\
	\\First post.

main! = |_args| show!(post)

show! = |text| {
	blocks = Markdown.parse_str(text)
	match Markdown.frontmatter(blocks) {
		Ok(raw) => {
			Stdout.line!("raw: ${Str.inspect(raw)}")?
			meta = Yaml.parse_str(raw)?
			Stdout.line!("meta: ${meta.to_inspect()}")?
			# The frontmatter is the first block.
			Stdout.line!("body blocks: ${(blocks.len() - 1).to_str()}")?
		}

		Err(Missing) => Stdout.line!("no frontmatter")?
	}
	Ok({})
}

Output:

raw: "title: Hello
draft: false
"
meta: Mapping([{ key: "title", value: Text("Hello") }, { key: "draft", value: Bool(False) }])
body blocks: 2

Without a closing --- line, the opening --- is ordinary Markdown: a thematic break, or a Setext heading underline.

3.5.4. Walk the tree to produce output§

To produce output, write two functions: one that matches each block variant and one that matches each inline variant. Each function calls the other for nested content. This section builds a small HTML renderer and a table-of-contents extractor for the following release notes:

blocks = Markdown.parse_str(source)
Stdout.write!(render_blocks(blocks))?
Stdout.line!("--- contents ---")?
for line in table_of_contents(blocks) {
	Stdout.line!(line)?
}

Escape the characters that are significant in HTML:

escape : Str -> Str
escape = |text| {
	text
		.replace_each("&", "&amp;")
		.replace_each("<", "&lt;")
		.replace_each(">", "&gt;")
		.replace_each("\"", "&quot;")
}

Render inline content:

render_inlines : List(Markdown.Inline) -> Str
render_inlines = |inlines| {
	var $out = ""
	for inline in inlines {
		$out = $out.concat(render_inline(inline))
	}
	$out
}

render_inline : Markdown.Inline -> Str
render_inline = |inline| {
	match inline {
		Text(text) => escape(text)
		Strong(children) => "<strong>${render_inlines(children)}</strong>"
		Emphasis(children) => "<em>${render_inlines(children)}</em>"
		Strikethrough(children) => "<del>${render_inlines(children)}</del>"
		InlineCode(code) => "<code>${escape(code)}</code>"
		Link({ label, target }) => "<a href=\"${escape(target.href)}\"${title_attr(target.title)}>${render_inlines(label)}</a>"
		Image({ alt, target }) => "<img src=\"${escape(target.href)}\" alt=\"${escape(plain_text(alt))}\"${title_attr(target.title)} />"
		HardBreak => "<br />\n"
		# Raw HTML passes through unchanged; see the warning above.
		HtmlInline(raw) => raw
	}
}

title_attr : Try(Str, [Missing]) -> Str
title_attr = |title| {
	match title {
		Ok(text) => " title=\"${escape(text)}\""
		Err(Missing) => ""
	}
}

Render blocks. A tight list item is rendered without its <p> wrapper:

render_blocks : List(Markdown) -> Str
render_blocks = |blocks| {
	var $out = ""
	for block in blocks {
		$out = $out.concat(render_block(block))
	}
	$out
}

render_block : Markdown -> Str
render_block = |block| {
	match block {
		Heading({ level, content }) => {
			n = level.to_str()
			"<h${n}>${render_inlines(content)}</h${n}>\n"
		}

		Paragraph(inlines) => "<p>${render_inlines(inlines)}</p>\n"
		Blockquote(children) => "<blockquote>\n${render_blocks(children)}</blockquote>\n"
		ListBlock({ kind, loose, items }) => {
			tag =
				match kind {
					Unordered => "ul"
					Ordered(_) => "ol"
				}
			var $body = ""
			for item in items {
				# A tight list shows its paragraphs without <p> tags.
				inner =
					match item.blocks {
						[Paragraph(inlines)] if !loose => render_inlines(inlines)
						_ => render_blocks(item.blocks).trim()
					}
				$body = $body.concat("<li>${inner}</li>\n")
			}
			"<${tag}>\n${$body}</${tag}>\n"
		}

		Code({ pre, .. }) => "<pre><code>${escape(pre)}</code></pre>\n"
		ThematicBreak => "<hr />\n"
		HtmlBlock(raw) => raw
		# Tables, frontmatter and the remaining variants are left out of this sketch.
		_ => ""
	}
}

A table of contents only needs headings and their plain text:

plain_text : List(Markdown.Inline) -> Str
plain_text = |inlines| {
	var $out = ""
	for inline in inlines {
		piece =
			match inline {
				Text(text) => text
				InlineCode(code) => code
				Strong(children) | Emphasis(children) | Strikethrough(children) => plain_text(children)
				Link({ label, .. }) => plain_text(label)
				Image({ alt, .. }) => plain_text(alt)
				HardBreak => " "
				HtmlInline(_) => ""
			}
		$out = $out.concat(piece)
	}
	$out
}

table_of_contents : List(Markdown) -> List(Str)
table_of_contents = |blocks| {
	var $lines = []
	for block in blocks {
		match block {
			Heading({ level, content }) => {
				indent =
					match level {
						One => ""
						Two => "  "
						_ => "    "
					}
				$lines = $lines.append("${indent}- ${plain_text(content)}")
			}

			_ => {}
		}
	}
	$lines
}

Output:

<h1>Release notes</h1>
<p>Version <strong>2</strong> is out. Read the <a href="/changes" title="All changes">changelog</a>.</p>
<h2>Fixed</h2>
<ul>
<li>Faster <code>parse</code></li>
<li>Safer <b>HTML</b> &amp; entities</li>
</ul>
<h2>Known issues</h2>
<p>None.</p>
--- contents ---
- Release notes
  - Fixed
  - Known issues

A complete renderer would also handle Table, use Code’s info for a language- class, and pass Ordered({ start }) through as the start attribute.

3.5.5. Conformance and limitations§

The API reference lists every type and function in the module.

3.5.6. Performance§

Markdown.parse_str parses about 30 MB/s on an Apple M2, building the block tree and every inline: a typical README in about 0.15 ms and a 1 MiB document in about 32 ms. That is about 2.7 times slower than goldmark (Go) and comrak (Rust), 7 times slower than pulldown-cmark, and 10 times faster than markdown-it-py. Parsing stays linear on runs of unmatched emphasis and brackets and on deeply nested block quotes and lists. See Performance for the measurements and caveats.

3.6. Reading AsciiDoc§

AsciiDoc parses an in-memory string into a document title and typed blocks, and renders embedded HTML. Paragraphs retain their inline source. Parsing normalizes line endings to LF. Like Asciidoctor, it removes trailing ASCII whitespace from each line of AsciiDoc source: the document and any included .adoc, .asciidoc, .asc, .ad or .txt file. Lines from any other included file, such as code or program output, keep their trailing whitespace wherever they land, and directives in them, such as ifdef:: or include::, are ordinary text.

3.6.1. Parsing and rendering§

parse_str uses the default attributes and leaves includes as links. parse_with accepts initial attributes and an explicit map of virtual include sources as a list of {path, source} records. Set expand_includes: True explicitly; the default is False. Includes never read files or the network. The parser supports nested includes, line and tag selectors, conditionals, attribute definitions and include level offsets. render converts a parsed document to embedded HTML; render_inline applies inline substitutions to a string and attributes.

For example, supply a virtual chapter using these options:

options = {
    ..AsciiDoc.default_options,
    includes: [{ path: "chapter.adoc", source: "Chapter text" }],
    expand_includes: True,
}

Listing, source, literal and verse blocks and paragraphs honour tabsize as Asciidoctor does. With the block’s own tabsize attribute or, when it has none, the document’s, each tab expands to the next multiple of that many columns, counting each character as one column. Zero, a negative number or text that isn’t a number expands nothing. An indented literal paragraph and passthrough content keep their tabs. A block’s indent applies after the expansion.

Block forms include sections, paragraphs, lists and description lists, listing and literal blocks, compound blocks, admonitions, tables, media and block metadata. Rendering handles inline formatting, macros, substitutions, cross references, section IDs, numbering, tables of contents, captions and footnotes. Rich blocks retain context, style, ID, attributes and nested blocks alongside the simpler paragraph, section, listing and literal variants.

Rich.attrs uses an immutable AsciiDoc.Attributes scope. Parsed nodes share their document attribute snapshot and keep local metadata separately, preserving attribute order, duplicates and empty values without copying the document bindings into each node. render_inline_attributes(text, node.attrs) renders against that scope. The existing render_inline(text, bindings) accepts a plain list for independent inline input.

Use materialize_attributes(node.attrs) when a flat list is required for serialization or editing; this explicitly enumerates the ordered bindings. Parsed source updates share balanced name indexes; materializing an updated scope sorts its bindings by their retained insertion positions, taking O(N log N) work at this explicit boundary. Unchanged seed scopes reuse their already ordered immutable bindings. For a manually constructed block, use attrs: AsciiDoc.attributes(bindings). To replace bindings, materialize and edit the list, then construct a new scope with AsciiDoc.attributes; the original snapshot remains unchanged.

Input is bounded to 256 KiB, expanded input to 1 MiB and structural nesting to 64 levels. Virtual includes default to a maximum depth of 16. Invalid input or resource-limit failures return a message, one-based line and zero-based UTF-8 byte offset. An initial UTF-8 byte order mark is ignored.

source = "= Guide\n\nIntroduction\n\n== Getting started\n\nHello\n"

describe : Str -> Str
describe = |text| {
	match AsciiDoc.parse_str(text) {
		Ok(document) => "${document.title}: ${document.blocks.len().to_str()} top-level blocks"
		Err(Unsupported(error)) => "Unsupported on line ${error.line.to_str()}: ${error.message}"
		Err(InvalidAsciiDoc(error)) => "Invalid on line ${error.line.to_str()}: ${error.message}"
	}
}

main! = |_| Stdout.line!(describe(source))

This complete example prints:

Guide: 2 top-level blocks

3.6.2. Reusing include selection§

A tool that expands some includes itself, for example to record where each listing came from, can call select_include instead of reimplementing the selection rules. It takes the included source and the directive’s attribute list, such as lines=1..4, tag=example,indent=0 or tags=**;!hidden. It returns the selected text, the source line number of each selected line, and a list of problems. include:: expansion calls the same code, so the two agree.

Selection is Asciidoctor 2.0.23’s, as lenient as it is. A missing tag selects nothing. lines=4abc selects line 4. A reversed range selects nothing, so lines=4..2 on its own selects every line. lines= overrides tag= and tags= whenever it is present, even with an empty value. Tags nest, and indent=0 removes leading tabs as well as spaces, one character each.

Three problems are Asciidoctor’s own warnings: MissingTag, UnclosedTag and UnmatchedEnd. The other two, InvalidLines and EmptySelection, flag selections that Asciidoctor makes without a warning. A strict caller rejects any problem, and a lenient caller ignores them. The API reference for AsciiDoc.IncludeProblem defines each one.

select_include reads its source as Asciidoctor reads an included file that isn’t AsciiDoc, such as code for a listing. When include:: expands an AsciiDoc file, it first removes trailing whitespace from each line, as Asciidoctor does, so a line of only spaces doesn’t count toward indent=.

In a document that sets tabsize, indent= first expands tabs. For such a document, call select_include_with(source, attributes, { tabsize: 4 }) with the document’s tab size. Asciidoctor ignores a tabsize= in the directive’s own attribute list, and so do both functions.

This example selects from a small Rust file:

fn greet(name: &str) {
    // tag::body[]
    let message = format!("Hello, {name}!");
    println!("{message}");
    // end::body[]
}
## Explain a problem, as a strict documentation build would.
describe : AsciiDoc.IncludeProblem -> Str
describe = |problem| {
	match problem {
		MissingTag(name) => "tag '${name}' never opens"
		UnclosedTag(name) => "tag '${name}' is not closed"
		UnmatchedEnd(name) => "end of '${name}' matches no open tag"
		InvalidLines(entry) => "lines entry '${entry}' is invalid"
		EmptySelection => "nothing is selected"
	}
}

## Select like `include::greet.rs[attributes]`, rejecting any problem.
strict : Str -> Str
strict = |attributes| {
	selection = AsciiDoc.select_include(greet_rs, attributes)
	if selection.problems.is_empty() {
		"${attributes} selects lines ${Str.inspect(selection.lines)}:\n${selection.text}"
	} else {
		"${attributes} is rejected: ${Str.join_with(selection.problems.map(describe), "; ")}"
	}
}

main! = |_| {
	for attributes in ["lines=3..4", "tag=body,indent=0", "tag=bdy", "lines=4..3"] {
		Stdout.line!(strict(attributes))?
	}
	Ok({})
}

It prints:

lines=3..4 selects lines [3, 4]:
    let message = format!("Hello, {name}!");
    println!("{message}");
tag=body,indent=0 selects lines [3, 4]:
let message = format!("Hello, {name}!");
println!("{message}");
tag=bdy is rejected: tag 'bdy' never opens; nothing is selected
lines=4..3 is rejected: lines entry '4..3' is invalid

The review fixtures in scripts/asciidoc/include-selection-fixtures.json compare these selections with Asciidoctor’s, including the inputs behind each problem.

3.6.3. Oracle coverage and implementation coverage§

The development oracle pins Ruby Asciidoctor to version 2.0.23 and verifies the gem’s SHA-256 before installing it. Its JSON protocol exposes a structural projection, richer block metadata, rendered HTML and warnings. The structural projection flattens Asciidoctor’s preamble wrapper and retains raw block source. It deliberately does not claim an inline semantic tree.

Authored fixtures assert reference behavior independently of the Roc parser. Strict review checks both the structural projection and rendered HTML against the pinned oracle. HTML comparison ignores layout indentation while retaining attributes, inline text and significant whitespace. Reference-accepted inputs that the implementation rejects fail the strict gate.

The oracle runs without ambient file or network access. Tests can supply a bounded virtual include map; the oracle applies its own include processing, including line and tag selectors, to those sources. Repository chapters are also checked individually, with unresolved includes left as links.

3.6.4. Reproduce the checks§

Use the latest Roc nightly, plus Ruby and Python:

python3 scripts/asciidoc/setup_oracle.py
mkdir -p .roc-parser-tmp/asciidoc
$ROC build --no-cache --opt=speed scripts/asciidoc/probe.roc --output=.roc-parser-tmp/asciidoc/probe
python3 scripts/review_asciidoc.py check --require-complete --compare-html --probe .roc-parser-tmp/asciidoc/probe --generated 200 --mutations 200 --corpus docs
python3 scripts/run_fuzz.py smoke asciidoc-raw asciidoc-structured

The review writes machine-readable evidence and retains failing inputs for replay under .roc-parser-tmp/asciidoc/. Use --oracle-only to validate the reference fixtures without a Roc probe. The native raw target checks crashes, error locations and LF/CRLF agreement; the structured target builds expected block trees independently from its input choices.

3.6.5. Local performance checks§

For fast local iteration, reuse the Ruby side of an existing saved run:

python3 scripts/check_asciidoc_performance_cached.py \
  --baseline /path/to/saved/results.json \
  --roc "$ROC" \
  --replay path/to/small-cases.json --output /tmp/asciidoc-cached-dev-1

When no binary is supplied, the command builds optimized native code with the pinned --roc compiler. To reuse an existing build, pass --candidate-binary with its genuine matching build receipt. The saved report SHA is always recorded; --baseline-sha256 optionally pins an expected value. The cached runner starts only that native worker. It checks fresh projection and DOM against the saved Ruby correctness hashes before and after timing, using the complete request (including ordered attributes and virtual includes) and mode. It defaults to quick 5 ms / three-sample native batches. --id ID can select a saved case without writing a replay file; repeat it for several IDs.

Treat ratios as rough internal guidance: large gains and geometric scaling warnings matter more than small near-parity differences. Saved Ruby sample quality, original failures, machine descriptions and reference code hashes remain visible; noisy or short historical samples receive warnings. New requests or changed semantics require a refresh. Noisy native samples remain visible too. Native requests time out after 10 seconds by default (--timeout overrides this). The first worker error is saved and stops the run; unprocessed inputs remain explicit. Include several sizes of each geometric family in a replay to inspect scaling; isolated maximum-size cases cannot establish growth.

Refresh with the existing fresh check_asciidoc_performance.py --replay ... command. Cached runs never claim fresh paired Oracle timing qualification and do not overwrite saved reports. This is an optional local development shortcut; it does not add CI jobs or change parser behavior.

Fresh reference measurements are available when refreshing the cache or investigating a meaningful slowdown:

The performance harness builds optimized native code and compares parsing and parsing plus HTML rendering with the same pinned Asciidoctor. Its default ceiling is 1.0 times the oracle duration for each input. Persistent workers keep IPC, JSON serialization, warmup and correctness checks outside timed batches.

python3 scripts/check_asciidoc_performance.py --roc "$ROC" --deadline-seconds 10800

The fresh campaign generates 2,000 seeded documents plus 500 raw mutations, runs the authored fixtures, and tests geometric sizes from 32 to 1,024 for paragraphs, sections, footnotes, cross references, lists, tables, attributes and delimiters. Footnotes (including table cells), named references, growing counter state (including formatted paragraphs and underscored names) and chained attribute definitions also run at 2,048 and 4,096 entries. Conditional presence and value tests exercise repeated lookups in a growing attribute environment. Explicit attribute substitutions, attribute passthroughs and explicit macro footnotes also run through 4,096 entries to exercise growing state in fallback substitution paths. Counter and set state mixed with immediate quoted passes, math and ordinary URLs also runs through 4,096 entries to catch repeated attribute materialization. Static header attributes mixed with URL paragraphs, URL list items, listing blocks, formatted section titles and AsciiDoc table cells check shared snapshot copies and repeated option lookups through 4,096 entries. The table-cell family explicitly disables headers and starts cell contents immediately after |. Static attribute environments with repeated and distinct image targets also run through 4,096 blocks to guard media option lookup and attribute sharing. Repeated source definitions followed by URLs or text, varied-name unset/readd cycles and many tiny tables currently use the bounded audit sizes 128, 256 and 512. Additional bounded families keep counters or set values live while unrelated source definitions change, including an explicit counter reset. A separate bounded family resets every live counter through a source definition and increments it again, then probes both endpoint counters. Alternating counter paragraphs and simple Body. paragraphs exercise transitions between Rich and ordinary parsed nodes while dynamic names grow, retaining counter, body and final-value predicates at the same bounded sizes. Ordered Options variants supply a same-value standard attribute or change and restore that attribute. Both retain ordered requests and the same HTML predicates to check initialization and baseline tracking across scope transitions. These families guard revision history, dynamic-state reconciliation and shared table scopes. The fixed performance-guards.json corpus appends 248 retained inputs after all historical inputs: standard-scope controls, small and ordered Options boundaries, and larger counter-reset and section-title cases. Title variants include ordinary and escaped quoted passes, role-prefixed passthroughs, explicit links, angle-wrapped HTTP/HTTPS links and literal angle prefixes, through 4,096 entries. These are strict performance targets, including retained failures; their presence does not claim passing timings. The extended guards exercise missing and newline-rejected literal closers, shared quote-role closers, Unicode role prefixes, malformed title macros and listing callouts through 4,096 entries. Callout cases include missing >, Unicode HTML-like text, many numbered callouts on separate lines or one line, and a growing placeholder-prefix collision. Two default-substitution families retain normal HTML-comment callouts and escaped numeric callouts through 4,096 entries. Their fixed sources keep cached comparisons reproducible. These remain local scaling inputs and do not expand the two-case CI smoke. Larger counter-preview, colliding attribute-name and alternating Options inputs retain the exact requests that exposed repeated copying and rescanning. Source-scaled fixed guards above 512 load only when the largest requested size is at least 1,024. A bounded campaign retains the other 134 fixed guards. Use --sizes 128 256 512 for a bounded local scaling campaign, or --replay with a retained exact-source case to isolate a measured failure. Stateful pass previews mixed with unrelated sets check work that can otherwise be discarded before shared-state evaluation. Positional mail subjects and bodies exercise protected-tag restoration with growing counter state. Table footnotes also run at 8,192 entries to check footer scaling. Nested containers use depths from 2 to 32. Scaling checks flag growth above the configured exponent. Reports record source hashes, compiler, machine, samples and replay commands. Failing inputs and scaling pairs are retained under .roc-parser-tmp/asciidoc-performance/. Replay cases may supply an ordered attributes list of {name, value} string records to exercise AsciiDoc.Options.attributes. Nonempty options are included in request fingerprints and retained replay cases; absent and empty options use the original request format and fingerprints. Entries keep their order and last duplicate wins. The pinned Ruby worker applies these as soft initial attributes, permitting subsequent document definitions to override them as the Roc parser does. Supplied values override the worker’s fixed initial attributes. Use ordinary lowercase attribute names for cross-engine parity; Asciidoctor’s API also interprets special attribute-name suffixes and normalizes names.

Scaling uses source UTF-8 byte length by default. Replay cases whose growing input is an ordered Options list or include map can set "scaling_input": "request". This measures the canonical compact UTF-8 JSON of source, includes, expansion mode and nonempty ordered attributes. Case IDs, measurement modes, iteration counts and warmup counts do not contribute to size. The flag changes only the scaling metric; worker requests and their fingerprints stay the same. Reports record the chosen basis and both measured input sizes. A family cannot mix source and request metrics, and cases cannot supply an arbitrary numeric size. The speed ceiling and scaling exponent stay unchanged.

Use --seed to explore another deterministic campaign, --corpus for local documents, and --replay to repeat a retained failure. --smoke runs two tiny correctness and protocol cases without asserting timing; CI runs this smoke check. The full timing gate is intended for a quiet local machine. For a stronger measurement of a retained input, use:

python3 scripts/check_asciidoc_performance.py --roc "$ROC" --replay path/to/cases.json --window-ms 50 --samples 7 --stable-all --progress

The standalone attribute-alias replay retains nine long stored values and late syntax-rejection inputs that exposed slow fallback scanning. It keeps their original ordered caller attributes and sources. Run it locally with a fresh output directory:

python3 scripts/check_asciidoc_performance.py --roc "$ROC" --replay scripts/asciidoc/performance-attribute-alias-controls.json --modes parse render --window-ms 150 --samples 15 --stable-all --batch-policy per-engine --max-ratio 1.0 --max-scaling-exponent 1.3 --output .roc-parser-tmp/asciidoc-alias-controls-attempt1

These are individual timing controls with no geometric scaling claim. They do not change the normal guard inventory or the two-case CI smoke check.

Document models are generated in Python. Each input then runs through both engines for fresh correctness checks, calibration, warmup and timing samples. The timing window is a minimum duration; it does not cap a single operation. A Ruby parse that takes seconds still runs in full for warmup and each sample. Use --progress to see the active case, engine and measurement phase. The default per-response wall budget is 180 seconds, allowing slow oracle warmup, calibration and fresh projection/HTML checks outside the timed interval. This response budget does not change the 1.0 times speed ceiling or sampling. The default full-campaign deadline remains 7,200 seconds; the command above allows 10,800 seconds for the expanded local corpus. Every request retains its fresh correctness checks.

The Ruby worker releases request-local correctness documents, projections, responses and include-source maps after each reply, including error replies. It clears the thread-local include map at the same boundary. This cleanup runs outside the elapsed interval; it prevents a prior correctness graph from remaining live during the next timed request. It does not force garbage collection inside measured operations.

The default --batch-policy per-engine calibrates an independent operation count for each engine. Every elapsed batch is divided by that engine’s count; full-duration measurements retain the requested minimum window for both engines, the requested sample count and alternating engine order. Fresh projection and HTML checks run before and after every case/mode measurement. Version 1 workers also return full output for each response; version 2 positive replies contain verified counts, checksum and elapsed time. Duration or noise refinement increases only affected engine counts within the existing attempt and iteration limits. Reports use schema version 2 and record both counts for every attempt; retained replay commands preserve the policy. Use --batch-policy common to reproduce the historical shared-count policy. Independent counts avoid repeating a slow oracle operation just because the native engine needs a larger batch. This reduces incidental oracle GC and rare-event averaging compared with longer shared-count batches; it is a new sampling policy, not a claim of identical statistical confidence. Historical reports remain unchanged and identify their original driver and policy.

Add --progress to flush the current case, mode, calibration engine and sample/attempt counts to stderr before each worker request. Completion and qualification messages identify long oracle responses without changing timed worker loops. The flag is opt-in and retained by replay commands.

The performance workers use reply protocol version 2. For every case and mode, the driver requests fresh full projection and HTML output before and after measurement, compares both engines and each engine’s before/after output, and checks the pinned oracle identity. Each zero-iteration reply also reports the metric from one separate fresh operation. Positive-iteration replies contain counts, checksum and elapsed time, avoiding an additional untimed conversion for each calibration or sample response. Every timed operation and warmup still constructs a fresh document; the driver requires its engine’s exact checksum (iterations + warmup) * operation_checksum.

A compact timing reply is accepted only inside the matching checked input, mode and worker-instance context, which expires on error or restart. Version 1 workers remain supported with their full per-response output comparisons. Reports retain before/after proof digests and raw timing attempts; correctness failures also retain full replies under the output’s correctness/ directory. Input fingerprints and retained replay sources are unchanged. This protocol does not reduce samples, duration windows, coverage or performance thresholds. The two-case CI smoke exercises the same correctness boundaries and compact protocol without asserting speed.

3.7. HTTP messages§

This chapter shows how to parse raw HTTP/1.x bytes into Roc records: a request or response line, its header fields, and its body. It is a set of how-to guides for readers who know basic Roc and are handling bytes from a socket, a test fixture or a capture file. By the end you can read requests and responses, decode chunked bodies, split pipelined messages, embed the parsers in a larger grammar, and know which inputs the parser refuses and why.

The HTTP module follows the message syntax of RFC 9112 and the field syntax of RFC 9110. It parses messages; it does not open connections or send anything.

3.7.1. Parse a request§

Parse a request with HTTP.parse_request. It takes the raw bytes as a List(U8) (use Str.to_utf8 for a Str) and returns the request and the rest of the bytes after it:

request_text = "POST /notes HTTP/1.1\r\nHost: example.com\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"

{ request, rest: _ } = HTTP.parse_request(request_text.to_utf8())?
Stdout.line!("method: ${method_name(request.method)}")?
Stdout.line!("target: ${request.target}")?
Stdout.line!("version: ${request.version.major.to_str()}.${request.version.minor.to_str()}")?
Stdout.line!("body: ${Str.from_utf8(request.body) ?? "<binary>"}")?

Output:

method: POST
target: /notes
version: 1.1
body: hello

A request is a record with these fields:

Turning a Method back into its name covers every case with one Extension branch:

## A method as it appears on the request line.
method_name : HTTP.Method -> Str
method_name = |method| {
	match method {
		Get => "GET"
		Head => "HEAD"
		Post => "POST"
		Put => "PUT"
		Delete => "DELETE"
		Connect => "CONNECT"
		Options => "OPTIONS"
		Trace => "TRACE"
		Patch => "PATCH"
		Extension(name) => name
	}
}

purge = HTTP.parse_request("PURGE /cache/a HTTP/1.1\r\nHost: example.com\r\n\r\n".to_utf8())?
Stdout.line!("method: ${Str.inspect(purge.request.method)}")?

Output:

method: Extension("PURGE")

3.7.1.1. Read header fields§

Look a field up with HTTP.header, which compares names case-insensitively (HTTP field names are) and returns the first match, or Err(Missing):

content_type = HTTP.header(request.headers, "content-type") ?? "none"
accept = HTTP.header(request.headers, "Accept") ?? "none"
Stdout.line!("content type: ${content_type}, accept: ${accept}")?

Output:

content type: text/plain, accept: none

Header names keep the case they were sent in. Values have surrounding spaces and tabs removed. A field that appears more than once appears in headers once per occurrence, so walk headers yourself to see every value.

3.7.2. Parse a response§

Parse a response with HTTP.parse_response. status_code is a U16 and reason is the reason phrase, which may be empty:

response_text = "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nNot here."

{ response, rest: _ } = HTTP.parse_response(response_text.to_utf8())?
Stdout.line!("status: ${response.status_code.to_str()} ${response.reason}")?
Stdout.line!("body: ${Str.from_utf8(response.body) ?? "<binary>"}")?

Output:

status: 404 Not Found
body: Not here.

3.7.3. Understand how the body is found§

The headers decide where the body ends (RFC 9112 section 6.3). The parser applies these rules in order:

  1. A response with a 1xx, 204 or 304 status has no body.

  2. Transfer-Encoding: chunked means the body is a series of chunks, which the parser decodes into one body.

  3. Content-Length: n means the body is exactly n bytes. If the input has fewer, parsing fails.

  4. With neither field, a request has no body, and a response’s body runs to the end of the input, because the server marks the end by closing the connection.

3.7.3.1. Decode a chunked body§

Chunk extensions (;name=value after a chunk size) and trailer fields (fields after the final 0 chunk) are checked for valid syntax and then discarded. Only the decoded content is kept.

chunked_text = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n6;note=x\r\npedia \r\n0\r\nExpires: never\r\n\r\n"

chunked = HTTP.parse_response(chunked_text.to_utf8())?
Stdout.line!("decoded body: ${Str.from_utf8(chunked.response.body) ?? "<binary>"}")?

Output:

decoded body: Wikipedia 

3.7.4. Read pipelined messages§

A client may send several requests on one connection without waiting for responses. HTTP.parse_request consumes exactly one message and returns the bytes that follow it as rest; parse rest again for the next message. To read a whole buffer of requests at once, use HTTP.parse_requests, which fails unless the buffer ends exactly at the end of a request:

pipelined = "GET /a HTTP/1.1\r\nHost: example.com\r\n\r\nGET /b HTTP/1.1\r\nHost: example.com\r\n\r\n".to_utf8()

first = HTTP.parse_request(pipelined)?
second = HTTP.parse_request(first.rest)?
Stdout.line!("first: ${first.request.target}, second: ${second.request.target}, left over: ${second.rest.len().to_str()} bytes")?

all = HTTP.parse_requests(pipelined)?
Stdout.line!("targets: ${Str.join_with(all.map(|r| r.target), ", ")}")?

Output:

first: /a, second: /b, left over: 0 bytes
targets: /a, /b

A response with no framing fields reads to the end of the input, so it is always the last message in a buffer.

3.7.5. Use the parsers inside a larger grammar§

HTTP.request and HTTP.response are the same parsers as a Parser(Utf8.Bytes, _), for when a message is one part of a larger format. They leave the bytes after the message for the next parser:

## A capture file: a one-line label, then the raw request.
capture : Parser(Utf8.Bytes, { label : Str, request : HTTP.Request })
capture =
	Parser.const(|label| |request| { label, request })
		.keep(Parser.chomp_while(|byte| byte != '\n').map(|bytes| Str.from_utf8_lossy(bytes)))
		.skip(Utf8.codeunit('\n'))
		.keep(HTTP.request)

saved = Utf8.parse_str(capture, "health check\nGET /health HTTP/1.1\r\nHost: example.com\r\n\r\n")?
Stdout.line!("${saved.label}: ${saved.request.target}")?

Output:

health check: /health

They fail with ParseError({ message, offset }). The message starts with invalid HTTP request: or invalid HTTP response:, and the offset is where the problem is, counted from the start of the whole input.

3.7.6. Rejected messages and request smuggling§

Request smuggling happens when two programs, such as a proxy and your server, disagree about where one message ends and the next begins. An attacker can then hide a second request inside the first one’s body. To avoid this, the parser rejects any message whose framing it would otherwise have to guess.

The parser rejects a message that has any of the following:

The status line has rules of its own: the status code is exactly three digits and at least 100, and the space before the reason phrase may be left out when the phrase is empty.

parse_request and parse_response fail with InvalidHttp(Error), where an Error is { offset, message }: the message says which rule was broken and the offset is the byte where the problem is. A framing error points at the start of the field line responsible; a missing Host field is reported at offset 0, the start of the message.

smuggled = "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 3\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"

match HTTP.parse_request(smuggled.to_utf8()) {
	Ok(_) => Stdout.line!("accepted")?
	Err(InvalidHttp({ offset, message })) => Stdout.line!("rejected at byte ${offset.to_str()}: ${message}")?
}

Output:

rejected at byte 55: both Transfer-Encoding and Content-Length

The fuzz/http-smuggling.roc fuzz target generates messages built from these constructs and checks that each one is rejected; see the fuzzing guide.

3.7.7. Out of scope§

The API reference lists the HTTP types and parsers.

3.7.8. Performance§

A typical request or response head parses in 1.5–2 µs on an Apple M2, as fast as Go’s net/http and about 3 times slower than Rust’s httparse, which checks less (see Compare equivalent work). Line ends, tokens and field values are checked 16 bytes at a time with SIMD. A Content-Length body is returned without copying, so body size barely affects the time; a chunked body is assembled once, in linear time even with thousands of one-byte chunks (20,000 of them in 0.7 ms). See Performance for the measurements.

3.8. Parsing Roc source§

This chapter is for authors of highlighters and source analysis tools. RocSyntax parses Roc source and returns source tokens, a syntax tree and diagnostics. The implementation is pure Roc; the compiler reference worker is development tooling and is not a dependency of applications using the module. Legacy Rust compiler syntax is outside its scope.

The syntax tree represents expressions, interpolated and multiline strings, typed literals, binding patterns, declarations, control flow, records, nominal construction, imports, type annotations and declarations, constraints and associated items. Module, hosted, app, package and platform headers retain their exposures, dependencies and target configuration. The compatibility section identifies the changing compiler grammar used for validation.

3.8.1. Parse and inspect tokens§

This complete example prints the parse status and the byte ranges of numeric tokens. The documentation check compiles and runs it against the package source.

main! = |_args| {
	parsed = RocSyntax.parse_str("answer = 1 + 2 * 3\n")
	Stdout.line!("status: ${Str.inspect(parsed.status)}")?
	Stdout.line!("diagnostics: ${parsed.diagnostics.len().to_str()}")?
	for token in parsed.tokens {
		if token.kind == Number {
			text = Str.from_utf8(parsed.source.sublist({ start: token.start, len: token.end - token.start })) ?? ""
			Stdout.line!("number ${text} at bytes ${token.start.to_str()}..${token.end.to_str()}")?
		}
	}
	Ok({})
}

Output:

status: Complete
diagnostics: 0
number 1 at bytes 9..10
number 2 at bytes 13..14
number 3 at bytes 17..18

3.8.2. Source locations and trees§

Tokens retain half-open UTF-8 byte spans: start includes the first byte and end points immediately after the token. Comments and whitespace remain in the token stream, so tooling can preserve the exact source even when it is incomplete. Keep the original source alongside the result when resolving spans.

The tree uses node indices and a separate child-index buffer. Follow a node’s child range through that buffer to visit its children; do not assume adjacent nodes are siblings. This representation supports large inputs without allocating a separate recursive container for every syntax construct.

Byte offsets are not character columns. An editor adapter must convert positions to the coordinate convention requested by its client. Build a line-start index once when converting many locations in the same source.

3.8.3. Highlighting and lint rules§

A highlighter can start from tokens for comments, keywords, strings, literals and punctuation. Syntax structure adds context for declarations, calls and expressions. Preserved tokens continue to be useful when a node is malformed.

Use RocSyntax.tokenize_str or tokenize_bytes when only lexical highlighting is needed. They return source-backed tokens and diagnostics without constructing a tree. RocSyntax.highlight_category(token.kind) maps each token to a presentation category without allocating strings or choosing theme colors. The runnable examples/roc-syntax.roc demonstrates both entry points and ships in the examples archive, where it is also checked against the bundled package.

Lint rules can traverse the tree and report diagnostics at source spans. Grouping and operator precedence are represented structurally. Rules that need binding or type information must add scope analysis or use compiler information; parsing alone does not establish that a program typechecks.

Check the parse status and diagnostics before applying a rule that assumes a complete tree. Recovery returns partial structure for malformed input. Nesting at the configured limit, or a document above the token limit, returns Limited; tokens remain available for highlighting. Code completion and highlighting should tolerate partial results.

3.8.4. Compatibility and validation§

The syntax reference is compiler commit f7bef6b38f23797eb34b291ced98af71ea37dd3b from upstream main. The implementation compiler is not pinned: CI builds and tests the package with the latest Roc nightly, and each validation run records the nightly it used. This identifies a changing language implementation, rather than a stable language specification. Updating the syntax reference is a separate maintenance decision.

The differential corpus records individual syntax rules and their coverage. For accepted input every compiler AST node must have a candidate node of the corresponding kind with the same byte span, apart from the leading . the compiler keeps in a name’s token and the arrow it keeps in front of a call. Raw fuzzing checks source preservation and structural invariants; structured generation checks independently constructed syntax. roc-syntax-spans holds any source to span invariants that need no reference: in a complete parse a node covers whole tokens and both halves of each delimiter pair, and in any parse children keep to their own bytes. roc-syntax-span-model writes files while noting each node’s exact span, including around comments and newlines inside delimiters, and the validation run reviews its corpus against the compiler. Performance runs use optimized builds and measure scaling as documents grow.

3.8.5. Run the feedback loop§

Install the latest Roc nightly, Zig 0.17.0 and Python 3, or let blueprint run roc-syntax provide Zig (Contributing to roc-parser). From the repository root, run:

ROC=/path/to/roc ZIG=/path/to/zig python3 scripts/check_roc_syntax.py

The command checks the package, builds the pinned compiler oracle and optimized Roc probe, checks the live protocol, compares curated cases plus 1,000 independently generated files, blocks, annotations and expressions, checks dedicated type, platform and adversarial grammar fixtures, compares acceptance across the complete pinned snapshot corpus, and compares tokens and normalized trees for every compiler-accepted input. It checks editor recovery, runs both fuzz smoke campaigns, compares mutated inputs with the compiler oracle, and checks performance scaling. It rebuilds candidate probes and records source and executable hashes. Its validation.json, step logs, differential failures and performance results live under .roc-parser-tmp/roc-syntax-validation/.

The first oracle build fetches the pinned compiler Git objects when no sibling compiler repository is available. --roc-source /path/to/roc selects an existing Git repository; its working files are ignored. Build caches stay under the validation output directory. Compiler backend libraries are not required.

Use --generated 10000 --mutation-runs 10000 --fuzz-runs 10000 for a larger campaign. Add --allocations to collect allocation/reallocation call counts. For performance comparisons, save the first run’s performance.json and pass it as --baseline /path/to/before.json on a later run on the same machine and compiler. An allocation call count does not measure bytes copied or peak memory; inspect timing growth too.

For the local per-input speed ratchet against the actual compiler parser, add --reference-performance full. Both workers parse the same immutable source repeatedly in optimized builds; startup, JSON transport and tree projection are outside the timed region. Reports contain per-input medians, samples and ratios, and retain failing inputs for replay. The target ceiling is --max-reference-ratio 1.5. Every input in the full comparison meets it on the development machine; the ratchet reports any input that regresses past it. See the measurement protocol and replay commands.

CI uses only --reference-performance smoke: two tiny inputs with a short measurement window and no speed-ratio assertion. On Linux the scaling check records the comment family but does not assert its ratio: the basic-cli host’s musl allocator can unmap and remap a page on every parse at some sizes, which hides the parser’s own scaling (basic-cli#530). The full comparative ratchet is a local development tool. Deep synthetic operator chains explicitly report partial depth-limited structural eligibility checks; ordinary conformance gates continue to require complete normalized tree comparisons.

Pull requests use seed 1 for reproducible differential generation. Scheduled and manual exploration use the GitHub run ID as a fresh reproducible seed; --seed RUN_ID reproduces that run locally. Reports retain complete source, models, commands and failures in the syntax validation artifact for 14 days. Promote minimized failures into committed fixtures before that artifact expires. Fresh seeds explore additional combinations; they do not prove grammar coverage. Scheduled exploration runs 10,000 generated cases and 10,000 mutations, restores the raw fuzz corpus and runs a fresh smoke campaign before oracle comparisons.

Generated models include headers, imports, type declarations, constraints and statement sequences. Six arithmetic operators use only the parentheses needed to preserve the independently constructed model. Use --expression-depth, --pattern-depth, --type-depth and --statements to tune bounded generation; reports record those budgets. See the model families and remaining omissions.

The editor recovery gate checks damaged files with healthy declarations before and after each edit, including UTF-8 comments and headers. Surviving declarations must retain their exact byte spans and independently specified trees. Diagnostics must stay inside the damaged region. Compiler rejection confirms that an edit is malformed; compiler recovery layout is not used as the candidate’s expected tree. These contracts cover specific edit classes rather than promising recovery from every possible incomplete program.

The oracle mutation lane edits accepted seeds from all four fixture sets and the complete upstream corpus. Every mutation must agree on acceptance; accepted mutations must also agree on tokens, normalized trees and node spans. The lane additionally compares unchanged UTF-8 inputs harvested from the raw fuzz corpus when present. Inputs that are not valid UTF-8 are compared too, and exclusions are counted explicitly. Mutation provenance records exact byte edits, seeds and original source so each failure can be replayed without the original corpus. See mutation campaign commands and limits.

To import the larger upstream source corpus, run:

python3 scripts/import_roc_syntax_snapshots.py --roc-source /path/to/roc

The importer reads the pinned commit’s immutable snapshot blobs and records source hashes, parse modes and skipped snapshot kinds. This is an exploratory coverage corpus, including invalid and historical examples. It is not a list of programs the current compiler promises to accept. Feed the generated fixtures to scripts/review_roc_syntax.py with the built probe and oracle commands. Every disagreement remains available for inspection; unknown projections and unsupported syntax must not be presented as structural agreement.

For each parser improvement, add a curated case for the relevant rule, check its normalized structure against the reference, extend the independent generator where useful, and rerun scaling when the parser’s traversal or allocation changes. Preserve minimized fuzz failures as source fixtures so the check survives corpus and generator changes.

3.9. Combinators by task§

This chapter is for readers who have finished Your first parser and are writing a parser of their own. It groups the combinators in the Parser and Utf8 modules by the job they do, and explains the three rules that most often surprise people: how alternatives backtrack, when repetition stops, and why parsers read bytes rather than characters. The API reference has the exact signature of every function.

The examples come from combinators-behaviour.roc; every expect in them passes.

3.9.1. Running a parser§

FunctionUse it to
Utf8.parse_str(p, str)Parse a whole Str. Returns Ok(value) or Err(ParseError({ message, offset })), where offset is the byte offset of the furthest failure. Input left over is a failure too.
Utf8.parse_bytes(p, bytes)The same for a List(U8).
Utf8.parse_str_partial(p, str), Utf8.parse_bytes_partial(p, bytes)Parse the start of the input and return { value, rest }, where rest is what is left. Use these to read one message from a stream that holds several.
Parser.parse(p, input), Parser.run(p, input)Run a parser whose input is not text, such as the CSV.Record that record parsers read. Call them as methods: p.parse(input).

3.9.2. Reading text§

All of these read from Utf8.Bytes, which is List(U8).

ParserReads
Utf8.codeunit(b)Exactly the byte b, such as Utf8.codeunit('=').
Utf8.codeunit_satisfies(pred)One byte that pred accepts.
Utf8.any_codeunitAny one byte.
Utf8.string(s), Utf8.utf8(bytes)Exactly the given text, returned as a Str or as bytes.
Utf8.digit, Utf8.digitsOne ASCII digit, or a run of them, as a U64. digits fails on a number too large for a U64.
Parser.chomp_while(pred)Bytes while pred accepts them. Always succeeds, possibly reading nothing.
Parser.chomp_until(b)Bytes up to, but not including, the byte b. Fails if b never appears.
Utf8.rest_str, Utf8.restAll remaining input, as a Str (failing if it is not valid UTF-8) or as bytes.

3.9.3. Sequencing and transforming§

Start a sequence with a value such as Parser.const(|a| |b| ...).

CombinatorDoes
Parser.const(v)Reads nothing and produces v, which can be a function that receives the results of subsequent keep calls.
p.keep(q)Runs q after p and passes its result to the function p produced.
p.skip(q)Runs q after p and discards q’s result.
p.map(f), Parser.map2, Parser.map3Transforms one, two or three results with an ordinary function.
p.between(open, close)Reads open, p, close and keeps only p’s result.
p.ignore()Keeps the input p reads but replaces its result with {}.
p.flatten()Turns a parser producing Try(a, Str) into one producing a, where Err(msg) becomes a parse failure with that message.

flatten is how you validate a value while parsing. Here a number above 65535 is rejected with a message of your choosing:

# Validate a value and turn a rejection into a parse failure.
port : Parser(Utf8.Bytes, U64)
port =
	Utf8.digits
		.map(
			|n| if n <= 65535 {
				Ok(n)
			} else {
				Err("port ${n.to_str()} is out of range")
			},
		)
		.flatten()

expect Utf8.parse_str(port, "8080") == Ok(8080)
expect Utf8.parse_str(port, "70000") == Err(ParseError({ message: "port 70000 is out of range", offset: 0 }))

3.9.4. Alternatives and optional parts§

CombinatorDoes
Parser.one_of(list)Tries each parser in order; the first success wins. On failure, reports the last parser’s message.
Parser.one_of(list), Parser.alt(p, q)The same for any input type. On failure, joins all the messages with "or".
Parser.maybe(p)Produces Ok(value) if p succeeds, or Err(Missing) without reading anything if it fails.
Parser.fail(message)Always fails with message. Useful as the last alternative.
# An optional leading sign, then digits.
signed : Parser(Utf8.Bytes, I64)
signed =
	Parser.const(
		|sign| |n| {
			match sign {
				Ok(_) => -(n.to_i64_wrap())
				Err(Missing) => n.to_i64_wrap()
			}
		},
	)
		.keep(Parser.maybe(Utf8.codeunit('-')))
		.keep(Utf8.digits)

expect Utf8.parse_str(signed, "-42") == Ok(-42)
expect Utf8.parse_str(signed, "42") == Ok(42)

3.9.4.1. Alternatives always backtrack§

When an alternative fails, the next one starts from the same position the first one started from, however much input the failed one had examined. There is no "commit" point, so you never need a try wrapper as in some other parser libraries:

# Both alternatives start with "ab". When the first fails at "d", the
# second starts again from the beginning of the same input.
abc_or_abd : Parser(Utf8.Bytes, Str)
abc_or_abd = Parser.one_of([Utf8.string("abc"), Utf8.string("abd")])

expect Utf8.parse_str(abc_or_abd, "abd") == Ok("abd")

The cost is that the first success wins, even when a later alternative would have read more. Put longer or more specific alternatives first:

# The first alternative that succeeds wins, even if a later one would
# consume more input. Put the longer keyword first.
keyword_short_first : Parser(Utf8.Bytes, Str)
keyword_short_first = Parser.one_of([Utf8.string("in"), Utf8.string("int")])

keyword_long_first : Parser(Utf8.Bytes, Str)
keyword_long_first = Parser.one_of([Utf8.string("int"), Utf8.string("in")])

expect Utf8.parse_str_partial(keyword_short_first, "int").map_ok(|r| r.rest) == Ok("t")
expect Utf8.parse_str(keyword_long_first, "int") == Ok("int")

With the short keyword first, "int" reads as "in" and leaves t behind.

Backtracking also means a failure deep inside one alternative is replaced by the failures of the ones after it. If error messages matter, keep the alternatives distinct from their first byte, so that only one of them gets far.

3.9.5. Repetition§

CombinatorProduces
Parser.many(p)Zero or more results of p.
Parser.one_or_more(p)One or more results; fails if the first p fails.
p.sep_by(sep)Zero or more results of p separated by sep. The separators are dropped.
p.sep_by_one_or_more(sep)The same, requiring at least one.

These combinators require the input type to support ==, which every input type in this package does. They use it to enforce the next rule.

3.9.5.1. Repetition stops when an element makes no progress§

Repetition ends at the first element that fails, or that succeeds without reading any input. Without the second condition, repeating a parser that can succeed on empty input, such as chomp_while or maybe(p), would loop forever:

# chomp_while always succeeds, possibly consuming nothing. `many` stops at
# the first element that makes no progress instead of looping forever.
runs : Parser(Utf8.Bytes, List(List(U8)))
runs = Parser.many(Parser.chomp_while(|b| b == 'a'))

expect Utf8.parse_str_partial(runs, "aab").map_ok(|r| r.rest) == Ok("b")

3.9.5.2. A failed element ends the repetition, not the parse§

When an element fails part-way through, repetition ends before that element and leaves its input unread. The repetition itself succeeds:

# A repeated element that fails part-way is not an error for `many`:
# repetition ends before that element, and its input is left unconsumed.
pair : Parser(Utf8.Bytes, U64)
pair = Utf8.digits.skip(Utf8.codeunit(';'))

expect Utf8.parse_str_partial(Parser.many(pair), "1;2;3x").map_ok(|r| r.rest) == Ok("3x")

The failure of the element that stopped the repetition is kept, though. So when you parse a whole input with Utf8.parse_str, a bad element in the middle of a list is reported by its own failure message and offset. Report parse errors shows how to turn that into a useful message.

3.9.6. Recursive structures§

A parser for a nested format refers to itself. Make it a function, because only functions can refer to themselves, and wrap the call in Parser.lazy so that it is built only when it is needed:

# A tree is a number or a bracketed, comma-separated list of trees: [1,[2,3]]
Tree := [Leaf(U64), Node(List(Tree))]

tree : {} -> Parser(Utf8.Bytes, Tree)
tree = |{}|
	Parser.one_of([
		Utf8.digits.map(|n| Leaf(n)),
		Parser.lazy(|_| tree({}))
			.sep_by(Utf8.codeunit(','))
			.between(Utf8.codeunit('['), Utf8.codeunit(']'))
			.map(|children| Node(children)),
	])

leaves : Tree -> U64
leaves = |t| {
	match t {
		Leaf(_) => 1
		Node(children) => children.fold(0, |sum, child| sum + leaves(child))
	}
}

expect Utf8.parse_str(tree({}), "[1,[2,3],[]]").map_ok(leaves) == Ok(3)

Two top-level parsers can refer to each other in the same way. One lazy in the cycle is enough: here expr uses term directly, and term reaches back to expr through lazy:

# A sum of two terms, where a term is a number or a bracketed sum: 1+(2+3)
term : Parser(Utf8.Bytes, U64)
term =
	Utf8.digits
		.alt(Parser.lazy(|_| expr).between(Utf8.codeunit('('), Utf8.codeunit(')')))

expr : Parser(Utf8.Bytes, U64)
expr =
	Parser.const(|x| |y| x + y)
		.keep(term)
		.skip(Utf8.codeunit('+'))
		.keep(term)

expect Utf8.parse_str(expr, "1+(2+3)") == Ok(6)

Without any lazy, the compiler rejects the pair: only functions can be recursive, and each parser is a value.

3.9.7. Bytes, not characters§

The Utf8 parsers read UTF-8 code units, which are bytes. An ASCII character is one byte, but other characters take two to four. é, for example, is the two bytes 0xC3 0xA9:

# Parsers read UTF-8 code units (bytes), not characters. "é" is two bytes.
expect Utf8.parse_str_partial(Utf8.any_codeunit, "é").map_ok(|r| r.value) == Ok(0xC3)
expect Utf8.parse_str(Utf8.string("é"), "é") == Ok("é")

This has three consequences:

3.9.8. Parsers over other input§

Parser(input, a) works for any input type, not only bytes. The CSV module’s record parsers read a CSV.Record, a list of fields, and CSV.field(p) runs a byte parser p on one field. To write a primitive parser for your own input type, use Parser.custom with a function from input to Ok({ value, rest }) or Err(ParseError({ message, offset })).

3.10. Parse a format of your own§

This guide shows how to turn a line-based text format into typed Roc values, with tests for each part and error messages that name the line at fault. It assumes you know the combinators from Your first parser.

The running example is an application log with one entry per line:

08:00 INFO started
12:30 WARN disk almost full

The finished program is custom-format-log.roc.

3.10.1. 1. Write down the result types§

Decide what a caller should get back before writing any parser. Use tags for fixed sets of words and records for groups of fields:

Level : [Info, Warn, Error]

Time : { hour : U64, minute : U64 }

Entry : { time : Time, level : Level, message : Str }

3.10.2. 2. Write one parser per part§

Give each part of the line its own parser, named after what it reads. Small parsers are easier to test, and their names make the larger parser read like the format’s description:

two_digits : Parser(Utf8.Bytes, U64)
two_digits =
	Parser.const(|tens| |ones| tens * 10 + ones)
		.keep(Utf8.digit)
		.keep(Utf8.digit)

time : Parser(Utf8.Bytes, Time)
time =
	Parser.const(|hour| |minute| { hour, minute })
		.keep(two_digits)
		.skip(Utf8.codeunit(':'))
		.keep(two_digits)
		.map(
			|t| if t.hour < 24 and t.minute < 60 {
				Ok(t)
			} else {
				Err("no such time")
			},
		)
		.flatten()

level : Parser(Utf8.Bytes, Level)
level =
	Parser.one_of([
		Parser.const(Info).skip(Utf8.string("INFO")),
		Parser.const(Warn).skip(Utf8.string("WARN")),
		Parser.const(Error).skip(Utf8.string("ERROR")),
	])

message : Parser(Utf8.Bytes, Str)
message = Utf8.rest_str

Notes on the choices made here:

3.10.3. 3. Combine the parts§

entry : Parser(Utf8.Bytes, Entry)
entry =
	Parser.const(|t| |l| |m| { time: t, level: l, message: m })
		.keep(time)
		.skip(Utf8.codeunit(' '))
		.keep(level)
		.skip(Utf8.codeunit(' '))
		.keep(message)

3.10.4. 4. Test each parser§

Put expect tests next to the parsers, covering at least one accepted and one rejected input for each:

expect Utf8.parse_str(time, "09:05") == Ok({ hour: 9, minute: 5 })
expect Utf8.parse_str(time, "24:00") == Err(ParseError({ message: "no such time", offset: 0 }))
expect Utf8.parse_str(level, "WARN") == Ok(Warn)

expect
	Utf8.parse_str(entry, "12:30 WARN disk almost full")
		== Ok({ time: { hour: 12, minute: 30 }, level: Warn, message: "disk almost full" })

expect Utf8.parse_str(entry, "12:30 DEBUG hello").is_err()

Run them:

roc test custom-format-log.roc

The compiler reports how many tests passed. A failing test prints the expression and the values on each side of ==.

3.10.5. 5. Split the input into records yourself§

For a format with one record per line, split the text into lines and parse each line separately, rather than describing the whole file with sep_by. You get three benefits:

Problem : { line : U64, reason : Str }

parse_log : Str -> Try(List(Entry), Problem)
parse_log = |text| {
	var $entries = []
	var $number = 0
	for line in text.split_on("\n") {
		$number = $number + 1
		match Utf8.parse_str(entry, line) {
			_ if line.is_empty() => {}
			Ok(e) => {
				$entries = $entries.append(e)
			}
			Err(ParseError({ message: reason, offset: _ })) => return Err({ line: $number, reason })
		}
	}
	Ok($entries)
}

This splits on \n only. If your files can come from Windows, also strip a trailing \r from each line.

3.10.6. 6. Use it§

level_name : Level -> Str
level_name = |l| {
	match l {
		Info => "info"
		Warn => "warning"
		Error => "error"
	}
}

summary : Str -> Str
summary = |text| {
	match parse_log(text) {
		Ok(entries) =>
			Str.join_with(entries.map(|e| "${level_name(e.level)} at ${e.time.hour.to_str()}h: ${e.message}"), "\n")
		Err({ line, reason }) => "line ${line.to_str()}: ${reason}"
	}
}

main! = |_args| {
	Stdout.line!(summary("08:00 INFO started\n12:30 WARN disk almost full\n"))?
	Stdout.line!(summary("08:00 INFO started\n\n25:00 ERROR late\n"))
}

Run the program:

roc custom-format-log.roc

Output:

info at 8h: started
warning at 12h: disk almost full
line 3: no such time

The second log has a blank second line, which is skipped, and a time that does not exist on the third line, which is reported with that line number.

3.10.7. When the format is not line-based§

If records can span lines, as in a nested or bracketed format, you cannot split the input first. Describe the whole input with one parser instead:

3.11. Report parse errors§

This guide shows what each module returns when its input is invalid, and how to turn that into a message a person can act on. The format parsers report invalid input as an Err value rather than crashing, and the repository’s fuzz tests check that they do.

The examples come from errors-report.roc, whose complete output is checked in as errors-report.expected.

3.11.1. Summary§

Every format type module reports invalid input with one tag named after the module, carrying a record with a message and the most precise location the format has. Combinator runners report the furthest failure.

FunctionErrorWhat it tells you
Yaml.parse_str, Yaml.decodeInvalidYaml({ line, column, message })Where the problem is and what it is. Columns count bytes.
Xml.parse_strInvalidXml({ line, column, message })Where the problem is and what it is. Columns count bytes.
JsonValue.parse_str, JsonValue.parse_withInvalidJsonValue({ line, column, message })Where the problem is and what it is, including a limit or check that an option enabled. Columns count bytes.
JsonValue.to_str, JsonValue.to_str_withInvalidNumber(text), DuplicateKey(key), TooDeepWhy a value built in code cannot be written as JSON.
CSV.parse, CSV.parse_with, CSV.parse_recordsInvalidCsv({ record, field, line, column, message })The record and field that failed, where they start in the text, and why: bad CSV syntax, a field your parser rejected, or a row of the wrong width.
CSV.parse, Yaml.decodeMissingRequiredField(name)A record field that is not a Try(_, [Missing]) had no column or key.
HTTP.parse_request, HTTP.parse_response, HTTP.parse_requestsInvalidHttp({ offset, message })Why the message was rejected, and the byte offset of the problem.
Markdown.parse_strNoneEvery input is a Markdown document, so parsing cannot fail.
Your own parsers, run with Utf8.parse_str or Parser.parseParseError({ message, offset })The furthest failure, or unexpected input where input was left over, with its byte offset.

All of these are open tag unions, so ? passes them through a function such as main! that returns other errors too, without map_err. That includes Roc’s builtin Json, whose InvalidJson carries a Str: JsonValue names its tag InvalidJsonValue, so the two never collide (see The builtin Json or JsonValue).

3.11.2. YAML, XML and JSON: show the location§

All three report one-based line and column numbers. Print them in the file:line:column: message form that editors and terminals recognise, so a user can jump straight to the problem:

yaml_message : Str, Str -> Str
yaml_message = |file_name, source| {
	match Yaml.parse_str(source) {
		Ok(_) => "${file_name}: ok"
		Err(InvalidYaml({ line, column, message })) =>
			"${file_name}:${line.to_str()}:${column.to_str()}: ${message}"
	}
}

xml_message : Str, Str -> Str
xml_message = |file_name, source| {
	match Xml.parse_str(source) {
		Ok(_) => "${file_name}: ok"
		Err(InvalidXml({ line, column, message })) =>
			"${file_name}:${line.to_str()}:${column.to_str()}: ${message}"
	}
}

json_message : Str, Str -> Str
json_message = |file_name, source| {
	match JsonValue.parse_str(source) {
		Ok(_) => "${file_name}: ok"
		Err(InvalidJsonValue({ line, column, message })) =>
			"${file_name}:${line.to_str()}:${column.to_str()}: ${message}"
	}
}

For the inputs "name: demo\nport: [8080\n", "<feed>\n <entry></feed>" and "{\n \"port\": 8080,\n}" these print:

config.yaml:2:7: unterminated flow collection
feed.xml:2:10: end tag </feed> does not match start tag <entry>
package.json:2:15: trailing comma is not allowed

YAML input outside the supported subset, such as an anchor or a tag, is reported the same way, with a message that names the feature. Tell users which subset you accept so that the message makes sense to them. JSON reports the limits and checks you enable with JsonValue.parse_with, such as the nesting limit or a repeated key, the same way too.

3.11.3. CSV: report the record and field§

Every CSV failure is InvalidCsv with the same record, whether the text is not valid CSV, a field does not match your parser, or a row has the wrong number of fields. The record and field numbers are one-based and suit a spreadsheet user; line and column point into the text, where the field starts:

Row : { name : Str, count : U64 }

row : Parser(CSV.Record, Row)
row =
	CSV.record(|name| |count| { name, count })
		.keep(CSV.field(CSV.string))
		.keep(CSV.field(CSV.u64))

csv_message : Str -> Str
csv_message = |source| {
	match CSV.parse_with(row, source) {
		Ok(rows) => "${rows.len().to_str()} rows"
		Err(InvalidCsv({ line, column, record, field, message })) =>
			"line ${line.to_str()}, column ${column.to_str()} (record ${record.to_str()}, field ${field.to_str()}): ${message}"
	}
}

For an unterminated quote (pears,"2), a non-number in a number column (pears,many), and a row with an extra column (apples,3,red), these print:

line 2, column 7 (record 2, field 2): unterminated quoted field
line 2, column 7 (record 2, field 2): expected a U64, found `many`
line 1, column 10 (record 1, field 3): the record has 3 fields, but the parser read only 2

The messages quote a short excerpt of the field, so a huge or binary field cannot flood a log. CSV.parse reports the same InvalidCsv errors, and adds MissingRequiredField(name) when the header has no column for a required record field (see Read CSV data).

3.11.4. HTTP: reject the message§

The HTTP parsers reject anything ambiguous, because a server and a proxy that disagree about where a message ends can be exploited. Treat every InvalidHttp error as a 400 Bad Request and close the connection; do not try to repair the message.

http_message : Str -> Str
http_message = |source| {
	match HTTP.parse_request(source.to_utf8()) {
		Ok({ request, rest: _ }) => "request for ${request.target}"
		Err(InvalidHttp({ message, offset })) => "400 Bad Request (byte ${offset.to_str()}): ${message}"
	}
}

For an HTTP/1.1 request without a Host field this prints:

400 Bad Request (byte 0): an HTTP/1.1 request needs a Host field

The message is meant for your logs. It may quote the client’s input, so think before sending it back in a response.

HTTP.parse_request returns the bytes after the message as rest, which is the next pipelined message, if any. HTTP.parse_requests reads a whole pipeline and fails if it does not end exactly at the end of a message.

3.11.5. Your own parsers: say where and what§

Utf8.parse_str returns the furthest failure: the one that read furthest into the input, even when a repetition stopped at it or an alternative recovered from it (see Combinators by task). Its offset is a byte offset. Three habits give users better messages:

Keep the raw failure message for logs and tests. It describes the parser, not the user’s input, so it is rarely the best thing to show a user on its own.

3.12. Parse and validate during compilation§

Compile-time parsing and useful validation are enduring design goals for every format in roc-parser. A quoted document or an adjacent imported file should become a ready-to-use Roc value, with invalid input explained during compilation. The same pure parser supports runtime input; checked conversions add configurable checks without changing the ordinary parsing APIs.

3.12.1. Quoted documents§

Annotate a quotation with its target type. Roc calls that type’s from_quote method during compilation. A malformed document, a failed decoding, or an enabled lint returns Err(BadQuotedBytes(message)), which Roc reports as a compiler error. Successful quotations become parsed constants.

Person : { name : Str, age : U8 }

people : CSV.Decoded(Person)
people =
	\\name,age
	\\Ada,36

table : CSV
table =
	\\name,age
	\\Ada,36

people.value holds typed records; table.records holds the raw CSV fields as bytes, including the header. Raw CSV does not assume its first row is a header.

Quotation typeAccess to the parsed result
YamlThe document tree itself
Yaml.Decoded(a).value, decoded as a
CSV.records, raw CSV records
CSV.Decoded(row).value, a list of decoded rows
XmlThe document’s .root and .declaration
JsonValueThe JSON value itself
Markdown.Document.blocks, a list of Markdown blocks
AsciiDoc.title and .blocks
HTTP.RequestLiteral, HTTP.ResponseLiteral.value, the request or response
RocSyntax.source, .tokens, .nodes, and the other parsed fields

YAML’s supported subset and XML’s well-formedness rules apply to quotations too. XML quotations do not validate an XSD or process namespaces. Roc source quotations check supported syntax, not names or types. HTTP quotations contain exactly one complete message, with the CRLF line endings its grammar requires. JSON quotations are strict RFC 8259 and reject repeated keys. Roc processes backslash escapes in multi-line strings too, so write a JSON escape such as \u00e9 with a doubled backslash, \\u00e9.

3.12.2. Adjacent files§

Keep the dedicated file extension for editor highlighting. Import the file as Str, then require a checked conversion in a top-level declaration:

import "assets/compile-time-settings.yaml" as settings_text : Str

Config : { name : Str, port : U16 }

config : Config
config = Literal.require(
	Yaml.validate_as({
		source: settings_text,
		name: "compile-time-settings.yaml",
		unknown_keys: Reject,
	}),
)

The imported file is read and the constant is parsed during compilation. The compiled program needs neither the file nor a runtime parser call for that constant. Roc file imports accept Str or List(U8); they do not invoke a custom type’s from_quote method directly.

Literal.require unwraps a successful checked conversion and fails with its message otherwise. Use it for demanded top-level constants. With dynamic input, keep the validator’s Try result and handle its error: a failing require call that executes at runtime crashes at runtime. Uninstantiated generic code is not a promise that its dependent quotations have been checked.

3.12.3. Props and extra checks§

Validators take a props record. source is required; name defaults to "<literal>". Other fields have defaults, so callers supply only overrides:

answer = Literal.require(
	Yaml.validate({
		source: "answer: yes",
		lints: { legacy_booleans: False },
	}),
)

This accepts the YAML string yes deliberately. Without the override, the legacy-boolean check suggests quoting it or using true or false.

Each format has validate; YAML and CSV also have validate_as for type-directed decoding. JSON has validate but no validate_as; decode JSON into records with Roc’s builtin Json. HTTP uses validate_request and validate_response. These functions return the natural parsed value in Try(_, [BadQuotedBytes(Str)]), so they can also be called from a custom type’s from_quote method.

The initial extra checks are enabled by default. Set an individual Boolean to False to accept that construct. Invalid syntax, type mismatches, unsupported features, and resource limits remain errors regardless of lint settings.

FormatLint fieldRejects
YAMLlegacy_booleansUnquoted yes, no, on, or off scalar values, ignoring case
CSVduplicate_headers, empty_headersDuplicate or empty names when a header mode is selected
XMLnon_utf8_declarationA non-UTF-8 declaration in already-decoded text
Markdownunclosed_fencesA code fence closed implicitly rather than by a closing marker
AsciiDocunexpanded_includesAn active include directive left unexpanded
HTTPrepeated_content_lengthRepeated identical Content-Length values
Roc sourceplaceholdersPlaceholder ... expressions

JSON has no lint fields. Its props take options, the same JsonValue.Options record that JsonValue.parse_with takes, so JSONC, a byte order mark, a duplicate-key policy such as CollapseIdentical, size limits and I-JSON numbers are chosen the same way at compile time as at run time (see Choose how strict to be).

Markdown’s ordinary parser accepts every string. Its fence check is an additional policy, not a claim that CommonMark requires closing fences. Checks use parsed context, so quoted strings, comments, escaped directives, and code are handled according to their format.

YAML typed props support keys (SnakeCase, KebabCase, or CamelCase) and unknown_keys (Skip or Reject), defaulting to SnakeCase and Skip. CSV props support header: Absent, Exact, or Normalized: raw validation defaults to Absent, typed validation to Exact. Normalized header checks use normalized names. Raw results retain the original header fields. AsciiDoc props expose explicit include sources and attributes, a depth limit of 16, and expand_includes: False by default. Validation never discovers or reads include files itself.

3.12.4. Simple, useful errors§

An error names the source and format, gives an available line and byte column, and explains the first problem. For example:

settings.yaml:2:7: YAML: unterminated flow collection

CSV errors also identify the record and field. Missing required fields are named even when there is no source coordinate. Syntax is checked before extra checks, and extra checks before decoding. Checks report the first relevant source location. These messages are a starting point that can improve without changing how callers select validation policy.

3.12.5. Your own quotation type§

Utf8.parse_quote adapts a combinator parser, requiring complete consumption and retaining its furthest-error location:

Count := { value : U64 }.{
	from_quote : Str -> Try(Count, [BadQuotedBytes(Str)])
	from_quote = |source| Utf8.parse_quote({ parser: Utf8.digits, source }).map_ok(|value| Count.{ value })
}

count : Count
count = "42"

For a format-specific application type, return a configured validate_as result from from_quote. This is also the place to add pure domain checks, returning a useful BadQuotedBytes explanation when they fail.

The complete example is checked, tested, and run in CI. Each format’s quotation and imported-file rejection contracts are checked by the compiler tests.

4. Reference§

4.1. Modules§

This chapter is a map of the package for readers who know some Roc and want to find the right module and entry point for an input format. It says what each module is for, which types and functions you start from, and how the modules fit together. The API reference has the full signature and documentation of every type and function, generated from the source.

4.1.1. The modules at a glance§

The package exposes eleven modules: parser building blocks, checked constant helpers, and parsers for data formats and Roc source.

ModuleUse it toStart from
AsciiDocParse and preprocess AsciiDoc, preserve source blocks and render embedded HTMLAsciiDoc.parse_str
ParserCombine small parsers into larger ones, for any input typeParser.const, keep, skip, map, one_of, many, sep_by
Utf8Parse text: match bytes, strings and digits, and run a parser on a StrUtf8.parse_str, string, codeunit, digits
LiteralRequire a checked imported constant, or format a quotation errorLiteral.require, Literal.error
CSVRead comma-separated values, raw or decoded into your own record typeCSV.parse, CSV.parse_with, CSV.parse_records
HTTPRead one HTTP/1.1 request or response message, including its bodyHTTP.parse_request, HTTP.parse_response, HTTP.header
JsonValueRead JSON of any shape as a lossless tree, and write it back outJsonValue.parse_str, JsonValue.parse_with, JsonValue.get_path, JsonValue.to_str
MarkdownTurn CommonMark and GitHub Flavored Markdown into a syntax treeMarkdown.parse_str, Markdown.parse_inlines
XmlCheck that an XML document is well-formed and read it as a treeXml.parse_str, Xml.Node.attribute, Xml.Node.children_named
YamlRead a YAML configuration file or Markdown frontmatterYaml.decode, Yaml.parse_str, Yaml.get_path
RocSyntaxPreserve Roc source tokens and build a syntax tree for highlighting and analysisRocSyntax.parse_str, RocSyntax.parse_bytes

4.1.2. How the modules fit together§

A Parser(input, a) is a value that describes how to read an a from the start of an input. Running it returns the value and the input it did not consume, or a ParseError with a message and an offset. Parser provides the combinators that build parsers from parsers; Utf8 provides the parsers that read text, with Utf8.Bytes (a List(U8)) as the input type.

Each format type module follows the same conventions:

Checked quotations use from_quote. Each format also offers configurable validation through defaulted props records; use Literal.require at top level to turn a checked imported file into a constant.

RocSyntax returns a tolerant parse result directly, with diagnostics and an explicit coverage status. It retains tokens even when source is incomplete. See Parsing Roc source for its source-location conventions and tooling use.

The following program demonstrates format and combinator entry points. It is docs/examples/reference-modules.roc in the repository, and CI runs it.

# Parser and Utf8: build a parser from combinators, run it on a Str.
pair : Parser(Utf8.Bytes, (U64, U64))
pair = Parser.const(|a| |b| (a, b)).keep(Utf8.digits).skip(Utf8.codeunit(',')).keep(Utf8.digits)

# CSV: decode each row into a record type the compiler infers from use.
people : Str -> Try(List({ name : Str, age : U64 }), [InvalidCsv(CSV.Error), MissingRequiredField(Str)])
people = |text| CSV.parse(text)

# HTTP: parse one message; bytes after it are left for the next message.
http_target : Str -> Str
http_target = |text| {
	match HTTP.parse_request(text.to_utf8()) {
		Ok({ request, rest: _ }) => request.target
		Err(InvalidHttp(e)) => "invalid request at byte ${e.offset.to_str()}"
	}
}

# Xml and Yaml: whole-document functions that report line and column.
xml_root : Str -> Str
xml_root = |text| {
	match Xml.parse_str(text) {
		Ok(doc) => Str.inspect(doc.root)
		Err(InvalidXml(e)) => "${e.line.to_str()}:${e.column.to_str()}: ${e.message}"
	}
}

yaml_value : Str -> Str
yaml_value = |text| {
	match Yaml.parse_str(text) {
		Ok(value) => value.to_inspect()
		Err(InvalidYaml(e)) => "${e.line.to_str()}:${e.column.to_str()}: ${e.message}"
	}
}

# Yaml can also decode straight into a record.
config : Str -> Try({ title : Str, draft : Bool }, [InvalidYaml(Yaml.Error), MissingRequiredField(Str)])
config = |text| Yaml.decode(text)

# JsonValue: a lossless tree; numbers keep their text, and it writes JSON too.
json_sorted : Str -> Str
json_sorted = |text| {
	match JsonValue.parse_str(text) {
		Ok(value) => JsonValue.to_str_with(value, { sort_keys: True }) ?? "not writable"
		Err(InvalidJsonValue(e)) => "${e.line.to_str()}:${e.column.to_str()}: ${e.message}"
	}
}

# Markdown: parsing never fails, so `parse_str` returns the blocks directly.
markdown_blocks : Str -> Str
markdown_blocks = |text| Str.join_with(Markdown.parse_str(text).map(Str.inspect), "\n")

Running it prints one line per call (the Markdown result prints two lines, one per block):

Ok((3, 4))
Ok([{ age: 36, name: "Ada" }, { age: 41, name: "Alan" }])
/index.html
Element({ attributes: [{ name: "lang", value: "en" }], children: [Text("hi")], name: "greeting" })
1:7: end tag </a> does not match start tag <b>
Mapping([{ key: "title", value: Text("Notes") }, { key: "draft", value: Bool(False) }])
Ok({ draft: False, title: "Notes" })
{"name":"web","version":2.10}
Heading({ level: One, content: [Text("Title")] })
Paragraph([Text("Some "), Emphasis([Text("text")]), Text(".")])

4.1.3. Parser§

Parser is the combinator library. Use it when you are writing a parser for a format this package does not cover, or extending one of the format parsers.

4.1.4. Utf8§

Utf8 holds the text parsers and the functions that run a parser on text.

The parsers work on bytes, not characters: codeunit('é') is not possible, because é is two bytes in UTF-8. Use string("é") instead.

4.1.5. CSV§

CSV reads RFC 4180 comma-separated values; the module documentation lists the exact dialect.

Every failure is InvalidCsv with the record, field, line and column.

4.1.6. HTTP§

HTTP reads one HTTP/1.1 (or HTTP/1.0) message: start line, header fields and body.

The parser decodes chunked bodies and rejects any message whose length is ambiguous, which is what protects a proxy from request smuggling. It does not open connections, send messages, or know which request a response answers; see Conformance for what that means for HEAD and CONNECT.

4.1.7. JsonValue§

JsonValue reads RFC 8259 JSON into a tree that keeps what a record type would lose: member order, the exact text of every number, and keys that are not Roc identifiers. Use Roc’s builtin Json instead to decode JSON straight into a record.

There is no streaming interface, no JSON5 beyond the listed relaxations, and no type-directed decoding. See Read and write JSON.

4.1.8. Markdown§

Markdown turns CommonMark 0.31.2 text, with the GitHub Flavored Markdown tables, task lists, strikethrough and extended autolinks, into a tree of Markdown blocks and Markdown.Inline nodes.

The module does not render HTML; walk the tree to produce your own output. Raw HTML and link destinations pass through unfiltered, so sanitize them before you render untrusted input (see Markdown).

4.1.9. Xml§

Xml checks XML 1.0 well-formedness and returns the document as a tree of Xml.Node values (Element({ name, attributes, children }) and Text).

Documents with a <!DOCTYPE> declaration are rejected, namespaces are not interpreted, and comments and processing instructions are checked and then dropped.

4.1.10. Yaml§

Yaml reads a practical subset of YAML 1.2 aimed at configuration files and Markdown frontmatter.

Anchors, aliases, tags, directives, complex keys, multi-line flow collections, multi-line plain or quoted scalars and multi-document streams are rejected rather than misread. Nesting is limited to 100 levels.

4.1.11. Choosing between modules§

If your input isUse
A small text format of your own, such as a command or a log lineParser with Utf8
A spreadsheet export or other comma-separated dataCSV
Raw bytes of an HTTP/1.1 message from a socket or a captureHTTP
Documentation, notes or a READMEMarkdown
A configuration file, or the frontmatter of a Markdown fileYaml
An XML document without a document type declaration, such as SVG or a feedXml
JSON whose shape you know, decoded into a recordRoc’s builtin Json
JSON of any shape, JSON with comments, or JSON that must be written back unchangedJsonValue
TOML, or YAML that uses anchors or several documentsAnother package: these modules do not support them

Conformance lists, for each format, the specification it follows, how that is checked, and the known gaps.

4.2. Conformance§

This chapter is for readers deciding whether a format parser is accurate enough for their input, and for contributors who need to reproduce the numbers. For each format it names the specification the parser follows, the reference implementation (the oracle) its results are compared with, the results last measured, and the known gaps.

4.2.1. How conformance is checked§

Each format has a review script in scripts/ that feeds a fixed set of inputs to a small Roc program (the probe) and to a pinned reference implementation, normalises both results and compares them. The inputs come from the format’s official test suite where one exists, plus hand-written cases in scripts/<format>/cases.json.

Every case where the library and the oracle disagree on purpose is listed in a known-failures.json file with its reason. A review fails when a case disagrees and is not listed, or when a listed case starts to agree (the list is stale). The scripts never update the list themselves.

Where the oracle is itself wrong, the script records an oracle disagreement and uses the specification’s expected result instead; these cases are reported but are not failures.

The AsciiDoc parser has a separate development harness: scripts/review_asciidoc.py. It pins Asciidoctor 2.0.23 and checks independently authored semantic assertions, a normalized block-source projection and embedded HTML. CI uses --require-complete --compare-html: reference-accepted parser rejections, projection differences and HTML differences fail. Generated models, reproducible mutations and repository document harvesting extend the authored cases. Reports retain original inputs, source hashes and failing reproducers. The separate local performance harness compares optimized native parsing and rendering with the same oracle and checks geometric scaling.

4.2.2. Results§

Run the reviews below for the source and compiler you intend to use. Reports identify accepted inputs, known divergences, unexpected failures and crashes. A passing finite corpus establishes agreement for those inputs and policies; it does not establish complete implementation of a specification. The per-format sections explain supported behavior and intentional differences.

4.2.3. Reproducing the results§

The review scripts need Python 3, the pinned oracles, and a Roc compiler. Set ROC to the compiler you want to test with; CI uses the latest Roc nightly (see Compatibility). scripts/check_conformance.roc -- installs every oracle into one environment and runs the reviews CI runs. To run them by hand, install each oracle into its own virtual environment under .roc-parser-tmp/, which git ignores:

export ROC=/path/to/roc
python3 -m venv .roc-parser-tmp/venv-yaml
.roc-parser-tmp/venv-yaml/bin/pip install --no-deps -r scripts/yaml/requirements.txt
python3 -m venv .roc-parser-tmp/venv-http
.roc-parser-tmp/venv-http/bin/pip install --no-deps -r scripts/http/requirements.txt
python3 -m venv .roc-parser-tmp/venv-md
.roc-parser-tmp/venv-md/bin/pip install --no-deps -r scripts/markdown/requirements.txt

Then run one command per format. CSV, XML and JSON need only the standard library. The XML script downloads the W3C suite on first use and checks its SHA-256; the JSON script downloads its five pinned documents the same way (--no-documents skips them).

python3 scripts/review_csv.py
python3 scripts/review_xml.py check --baseline scripts/xml/known-failures.json
python3 scripts/review_json.py check --baseline scripts/json/known-failures.json
.roc-parser-tmp/venv-http/bin/python scripts/review_http.py
.roc-parser-tmp/venv-md/bin/python scripts/review_markdown.py check
.roc-parser-tmp/venv-md/bin/python scripts/review_markdown_blocks.py check --baseline scripts/markdown/blocks/known-failures.json
.roc-parser-tmp/venv-yaml/bin/python scripts/review_yaml.py check --baseline scripts/yaml/known-failures.json

Each script prints a summary, such as the following for HTTP:

134 cases, 22 known divergences, 0 failures, 0 crashes, 0 stale (h11 0.16.0)

The XML, YAML, JSON and Markdown block scripts also write a JSON report, with every case’s expected and actual result, under .roc-parser-tmp/. The JSON script writes .roc-parser-tmp/json-review/check.json.

4.2.4. CSV§

The parser follows RFC 4180 with the relaxations most CSV readers share: LF and lone CR line breaks as well as CRLF, an optional final line break, rows of different lengths, and any UTF-8 in unquoted fields. The CSV module documentation lists the dialect exactly.

scripts/review_csv.py compares the raw records with Python’s csv.reader in strict mode, on the fixed cases and 2000 random inputs from a fixed seed.

Known differences, by design:

4.2.5. HTTP§

The parser follows the message syntax of RFC 9112 and the field syntax of RFC 9110. It is a message parser: it reads bytes that are already in memory and does not manage connections.

scripts/review_http.py compares the verdict, start line, fields, decoded body and leftover bytes with h11. Most of the 22 listed divergences are places where RFC 9112 allows a parser to reject something and this one does, as llhttp does by default while h11 accepts it: line folding, bare LF line endings, control characters in field values, Transfer-Encoding in an HTTP/1.0 message, and a message with both Transfer-Encoding and Content-Length. Rejecting these is what prevents request smuggling. The others are by design:

Limitations:

4.2.6. JSON§

The parser follows RFC 8259. By default it is strict and also rejects repeated keys; options add relaxations and checks (see Choose how strict to be).

scripts/review_json.py compares the probe with Python’s standard json module, configured so that it models JsonValue.Options:

Every accepted value is also written compact, with indent: 2 and with sort_keys: True, and compared byte for byte with what json.dumps writes. Rejections are compared as verdicts, and an error’s line and column must lie inside the input. Each case also checks that JsonValue.parser_with agrees with JsonValue.parse_with.

The inputs are:

All 720 cases pass, with no known failures and no oracle disagreements. The 35 implementation-defined suite cases are recorded, never counted as failures. 10 are accepted: numbers too large or too small for a double, whose text is kept. 25 are rejected: 10 lone or malformed surrogate escapes, which a Roc Str cannot hold (lone_surrogates: Replace reads them as U+FFFD); 13 inputs that are not UTF-8, such as UTF-16, ISO-8859-1, overlong sequences and encoded surrogates; a byte order mark, which bom: True accepts; and 500 nested arrays, past the default 128-level limit.

Two suite cases are policy deviations. y_object_duplicated_key and y_object_duplicated_key_and_value are valid RFC 8259 text, but strict parsing rejects a repeated key on purpose. The review checks them with duplicates: KeepLast, which is how Python reads them, and lists them under policy_deviations in its report.

Limitations: the input must be UTF-8, the relaxations stop well short of JSON5, and there is no streaming interface. The JsonValue module documentation lists every option.

4.2.7. Markdown§

The parser follows CommonMark 0.31.2 with the GitHub Flavored Markdown extensions for tables, task list items, strikethrough and extended autolinks. It also reads a frontmatter block between --- lines at the start of a document.

Two scripts check it:

All 76 known failures of the block check are differences of representation or of the oracle, not wrong structure:

The known-failures file does not yet give each entry its reason; the grouping above comes from comparing the expected and actual trees.

Markdown has no syntax errors, so every input produces a document.

4.2.8. XML§

The parser checks well-formedness as defined by XML 1.0 (Fifth Edition) for documents without a document type declaration. It does not validate.

scripts/review_xml.py compares verdicts and trees with expat and selects the W3C conformance cases inside the supported subset: UTF-8 text, XML 1.0, no <!DOCTYPE>. The two known failures are:

The 11 oracle disagreements are cases where expat uses older name-character tables than the Fifth Edition; the suite’s verdict is used.

Limitations: document type declarations and therefore all entities other than the five predefined ones are rejected, namespaces are not processed, the input must be a Str (already decoded), and comments and processing instructions are dropped from the tree.

4.2.9. YAML§

The parser implements a subset of YAML 1.2 for configuration files and frontmatter, and resolves plain scalars with the core schema.

scripts/review_yaml.py runs every yaml-test-suite case and the hand-written cases, and compares the value with ruamel.yaml’s pure-Python parser. Of the 803 cases:

Two of the 206 rejections, limits/flow-depth-100 and limits/flow-depth-512, come from the 100-level nesting limit, which now also applies to flow collections; scripts/yaml/known-failures.json records them with that reason. The 58 oracle disagreements are cases where ruamel.yaml differs from the test suite’s expected result; the suite wins.

Limitations: besides the features above, integers must fit in I64, tabs may not be used for indentation, and nesting deeper than 100 levels is rejected. The Yaml module documentation describes the subset.

4.2.10. Property testing§

Conformance checks compare chosen inputs with an oracle. Alongside them, every parser runs under coverage-guided property tests built with roc-fuzz, which generate inputs, including malformed and pathological ones, and check properties such as: the parser never crashes or hangs, rendering a value and parsing it again gives the same value, and results agree with a simple model. The review scripts can also compare a fuzzing corpus with the oracles. CI runs a short campaign for every target on each pull request and a longer one every night. Property testing describes the targets and how to run them.

4.3. Compatibility§

This chapter is for readers choosing a release, upgrading, or matching a Roc compiler to the package. It states which compiler each release is built for, what a version number promises, where the package runs, and what changed in 2.0.0.

4.3.1. Roc compiler§

Roc has no stable release yet, and its compiler changes the language and standard library often. roc-parser follows the latest nightly rather than pinning one:

Use the latest nightly with the latest release. If a newer nightly breaks a release you depend on, the nightly its notes name under "Compilers" is known to work; the release notes also say when a migration changed the package’s API.

roc version

4.3.2. Version numbers§

Releases follow semantic versioning:

For a parser, which inputs it accepts and the values it produces matter as much as its type signatures. Read the release notes for behaviour changes even when the API is unchanged.

Roc packages are fetched by URL, so a release is a bundle URL whose name is the hash of its content. Copy it from the GitHub releases page; each release also publishes an SBOM and signed provenance.

4.3.3. Platforms§

The package is plain Roc with no platform-specific code: it does no input or output, so it works with any Roc platform, on any system where that platform and the Roc compiler run. CI builds and tests it on Linux x86-64, and the conformance results in Conformance were measured on macOS with Apple silicon.

4.3.4. Dependencies§

The package depends on one other package:

PackageVersionUsed for
roc-lang/unicode4.3.0Unicode case folding, general categories and scalar handling in the Markdown module

package/main.roc names its release URL. Roc fetches it when it fetches roc-parser, so applications need not add it themselves.

4.3.5. The builtin Json module§

Roc auto-imports a builtin module named Json, which decodes JSON into records. roc-parser’s JSON module is therefore named JsonValue: an import parser.Json would leave the alias unbound. For the same reason its errors use the tag InvalidJsonValue: the builtin reports invalid input as InvalidJson with a Str, and keeping the tags apart lets one function pass errors from both on with ? (see The builtin Json or JsonValue).

4.3.6. Changes in 2.0.0 at a glance§

Version 2.0.0 changes the API of every module and reworks every format parser to follow its specification, checked against a reference implementation (see Conformance). Code written for 1.x does not compile without changes, and many inputs now parse differently. The 2.0.0 release notes start with an upgrade guide that maps each old name to its replacement. In summary:

ModuleWhat changed
Parser, Utf8The text parsers live in the Utf8 module. Runners report the furthest failure as ParseError({ message, offset }), and partial runners return { value, rest }. maybe returns Try(a, [Missing]). Several functions were renamed or removed, and many and sep_by stop when an element consumes no input instead of looping forever.
CSVCSV.parse decodes rows into records by header name. CSV.Record replaces the opaque record types, and every failure is InvalidCsv({ record, field, line, column, message }). Parses RFC 4180, with an optional final line break, lone CR line breaks and any UTF-8 in fields.
HTTPparse_request, parse_response and parse_requests take bytes and fail with InvalidHttp({ offset, message }). Headers are { name, value } records with a case-insensitive HTTP.header lookup, Version is now { major, minor }, and Method gains Extension(name). Parses HTTP/1.1 per RFC 9112 and rejects messages with ambiguous framing.
MarkdownMarkdown.parse_str and parse_inlines return the tree directly, and the legacy single-construct parsers, the placeholder variant and the debug printers are gone. Rebuilt on the CommonMark 0.31.2 parsing strategy, with GFM tables, task lists, strikethrough and extended autolinks. Adds the roc-lang/unicode dependency.
XmlXml.parse_str reports InvalidXml({ line, column, message }), Xml.parser embeds the parser, elements are records, and optional parts of the declaration are Try(_, [Missing]). Checks XML 1.0 well-formedness and rejects documents with a document type declaration.
YamlYaml.decode reads a document into a record. Text scalars are Text, errors are InvalidYaml, and helpers such as get_path and as_str walk the tree. Resolves plain scalars with the YAML 1.2 core schema and rejects unsupported features, such as anchors, instead of misreading them.

4.4. Performance§

Use this chapter to assess memory, bound untrusted input and choose what to measure. Throughput depends on the compiler, document shape and result your application needs. See Benchmarks for reproducible comparisons.

4.4.1. Summary§

Measure the package release and compiler you will deploy, using representative inputs and increasing sizes. Keep elapsed time, allocation traffic and resident memory separate. Benchmark reports record their source and toolchain identities; old measurements do not establish the performance of a current nightly.

4.4.2. Choose an appropriate workload§

roc-parser is a pure-Roc package that returns typed values and structured errors. Every parser needs the complete input in memory. It has no streaming or event interface, so measure carefully when parsing is a high-volume bottleneck or inputs approach your application’s memory limit.

A parser returning a full tree does different work from a tokenizer returning borrowed events. Compare the representation and validation your caller actually needs, rather than selecting a library from a single throughput ratio.

4.4.3. Bound work on untrusted input§

The format parsers use byte scanners, explicit state and bounded nesting. Per-input deadlines in the property tests detect excessive work; a bounded search does not prove a complexity bound for every input.

Recursive custom combinators still use the call stack. Impose a nesting limit for untrusted recursive formats and test the limit and the first rejected input.

4.4.4. Memory behaviour§

The complete input and parsed values contribute to peak memory. Allocation traffic also depends on how many fields, nodes and decoded strings are created; byte length alone does not describe the workload.

Text without escapes can share the input: XML text and attributes, YAML scalars and keys, JSON strings and number text, and HTTP field values use slices where possible. Rewritten escapes, entities and block scalars need new bytes. A Content-Length body shares input memory; a chunked body is assembled.

A small retained slice can keep a large input alive. Copy values deliberately when the caller must release the original document. Measure retained memory separately from cumulative requested bytes and process RSS.

4.4.5. Compare equivalent work§

Benchmark drivers read input before the timed loop, consume the result and record failures. Review their source when comparing libraries:

Count only inputs accepted under the comparison’s declared policy. Report rejections and resource limits separately from successful timings.

4.4.6. Costs to measure§

Building an owned result tree allocates and later frees lists, strings and records. Sharing introduces reference-count work. Repeated conversion or reconstruction of text can add copies even when the parser’s scanner is linear.

Profile the deployed compiler and backend before naming a cause. Keep a minimal control that distinguishes scanning, decoding and result construction. Changes to compiler ownership analysis can alter allocation costs without changing the high-level Roc source.

4.4.7. Byte scanning and slices§

Utf8.ByteClass describes a set of bytes. Utf8.find_any, Utf8.skip_class, Utf8.find_line_end and Utf8.span_class examine runs with U8x16, then handle remaining bytes with a scalar loop. Build reusable classes outside hot loops. The gain depends on the distance between matches and the amount of result construction after each match.

The parsers track byte offsets and return slices where possible. Converting bytes with Str.from_utf8 validates UTF-8; XML can instead cut names and text from an already validated string with boundary-checked byte slices. Sharing avoids copies but retains the original allocation while a slice is reachable.

4.4.8. Tips for your own parsers§

4.5. Glossary§

The words this manual uses with a specific meaning, in alphabetical order. Terms that belong to one format name the format in parentheses.

4.5.1. Backtracking§

Returning to an earlier position in the input to try another alternative after one fails. Parser.alt and one_of always backtrack: each alternative starts from the input the first one was given, however much the failed one read.

4.5.2. Chomping (YAML)§

How a block scalar (| or >) treats the line breaks at its end. Clip (the default) keeps one, strip (-) keeps none, and keep (``) keeps them all. Unrelated to Parser.chomp_while and chomp_until, which consume input while or until a condition holds.

4.5.3. Combinator§

A function that builds a parser from other parsers, such as keep, skip, many or one_of. A parser for a whole format is built by combining small ones. See Modules.

4.5.4. Consumption§

The part of the input a parser has read when it succeeds. The rest is passed to the next parser. A parser that succeeds without consuming anything makes no progress; many and sep_by stop repeating when an element makes no progress, so they cannot loop forever.

4.5.5. Core schema (YAML)§

The YAML 1.2 rules that decide what an unquoted (plain) scalar means: null and ~ are null, true and false are Booleans, and numbers such as 12, 0o14, 0xC, 1.5 and .inf are integers or floats. Anything else is a string. Quoted scalars are always strings.

4.5.6. Flanking (CommonMark)§

The rule that decides whether a run of * or _ can open or close emphasis. A run is left-flanking when it is not followed by whitespace and, if it is followed by punctuation, is preceded by whitespace or punctuation; right-flanking is the mirror image. So *foo* is emphasis but * foo * is not.

4.5.7. Format§

In type-directed decoding, a type whose methods (parse_str, parse_record_field, parse_list_start and so on) read one value at a time from the input. CSV.Format and Yaml.Format are formats; you use them through CSV.parse and Yaml.decode and never name them. See parser_for.

4.5.8. Framing (HTTP)§

How the receiver finds where a message body ends: by Transfer-Encoding: chunked, by Content-Length, or, for a response with neither, by the end of the connection. See smuggling.

4.5.9. Furthest failure§

Of all the failures seen while parsing, the one that read furthest into the input, including failures that an alternative or a repetition recovered from. The runners report it as ParseError({ message, offset }), so the error points at the bad element and not at the input left over after it.

4.5.10. I-JSON§

The Internet JSON profile of RFC 7493: JSON restricted so that every reader gets the same data. Among other rules, numbers should fit an IEEE 754 double and keys must be unique. JsonValue.parse_with checks its number rule with interoperable_numbers: True, and rejects repeated keys by default. See Choose how strict to be.

4.5.11. Input§

The data a parser reads, given by the first type parameter of Parser(input, a). For text it is Utf8.Bytes, a list of UTF-8 bytes; for CSV record decoding it is a CSV.Record, a list of fields.

4.5.12. JSONC§

JSON with comments: JSON that also allows // and /* */ comments and, in most tools, trailing commas, as in tsconfig.json and editor settings. JsonValue.parse_with reads it with comments: True and trailing_commas: True. It is not JSON5. See Choose how strict to be.

4.5.13. Known failure§

A conformance case where the library and the oracle disagree on purpose, listed with its reason in a known-failures.json file. See Conformance.

4.5.14. Method§

A function that belongs to a type, defined in the .{ } block after the type declaration and called with a dot on a value: parser.keep(field) calls Parser.keep(parser, field). This manual writes calls in that method-call style. The compiler finds the method from the value’s type, which is static dispatch.

4.5.15. Opaque nominal type§

A nominal type declared with ::, whose backing representation is hidden outside its module, so values can only be built and read through its methods. Parser(input, a) is one. A nominal type declared with :=, such as Yaml, JsonValue or Markdown, shows its tags, so you can match on them.

4.5.16. Oracle§

A trusted reference implementation whose results a test compares with the library’s, such as Python’s csv module for CSV or expat for XML. When the oracle is itself wrong, the specification decides. See Conformance.

4.5.17. Parseable§

A where-clause alias, row.Parseable(errs), that CSV.parse and the other decoding functions use to say "a type that this format can decode" without naming the format’s internal state type. You rarely write it yourself.

4.5.18. Parser§

A value of type Parser(input, a): a description of how to read an a from the start of an input. Running it returns the value and the unconsumed input, or a ParseError with a message and an offset.

4.5.19. parser_for§

The method the compiler derives for records, tuples, lists and the builtin scalar types, which reads a value of that type with a given format. It is how CSV.parse and Yaml.decode decode into the type your annotation names. A field of type Try(a, [Missing]) is optional; a required field that is absent fails with MissingRequiredField(name), which the compiler adds to the error type.

4.5.20. Parsing incomplete§

The parser succeeded but did not consume the whole input. Functions such as Utf8.parse_str, which expect to read everything, report it as a ParseError at the furthest failure, or as unexpected input at the leftover.

4.5.21. Progress§

See consumption.

4.5.22. Property test§

A test that generates many inputs and checks a rule that must hold for every one of them, such as "the parser never crashes" or "parsing a rendered value gives back the value". This package runs its property tests with coverage guidance from the roc-fuzz library. See Property testing.

4.5.23. Request smuggling (HTTP)§

An attack in which two programs that read the same bytes, such as a proxy and a server, disagree on where one message ends. The attacker hides a second request inside the first one’s body. A parser prevents it by rejecting any message whose framing could be read two ways.

4.5.24. Soft line break (CommonMark)§

A line break inside a paragraph that is not a hard break. HTML renders it as a space; this library keeps it as \n in the Text node.

4.5.25. Static dispatch§

Choosing which function a method call runs from the type of the value, at compile time. It is how value.to_inspect() finds the Yaml version for a Yaml value, and how CSV.parse finds the parser_for of your row type.

4.5.26. Try§

Roc’s builtin type for a result that may fail: Try(ok, err) is Ok(value) or Err(problem). Every fallible function in this package returns one. An optional value is Try(a, [Missing]).

4.5.27. Type module§

A module named after the one type it defines, with that type’s methods in the .{ } block of its declaration. Parser, Yaml, Xml, JsonValue and Markdown are type modules. Utf8, CSV and HTTP define their type as [], an empty tag union, because they group functions and nested types rather than values of their own.

4.5.28. Well-formed (XML)§

Following the syntax rules of XML 1.0: one root element, properly nested and matching tags, quoted and unique attributes, and only declared entities. Well-formedness says nothing about which elements are allowed; that is validity, which needs a schema or DTD and which this package does not check.

4.5.29. Where clause§

The part of a type annotation that lists the methods a type variable must have, such as where [input.len : input -> U64] on Parser.many, which needs only the length of its input to detect progress.

5. Contributing§

5.1. Contributing to roc-parser§

This chapter is for people who want to change roc-parser: fix a bug, improve a format parser, or improve this manual. It assumes you can read Roc and use Git and Python 3. When you finish it you will have a working checkout, know which checks CI runs, and know what a pull request needs before it can be merged.

Related chapters: Property tests and conformance reviews explains the fuzz targets and conformance reviews, Add a format module walks through adding a new format module, and Compiler, releases, settings and security reports covers the compiler, releases and repository settings.

5.1.1. Report a problem or propose a change§

Report bugs and propose changes in the roc-parser issue tracker. For a parsing bug, include the roc-parser release or commit, the output of roc version, and the smallest input that shows the problem. For a change to a public API or to the syntax a format accepts, open an issue first so the shape can be agreed before you write the code.

Do not report a suspected vulnerability in a public issue. Follow the private process in the security policy instead.

5.1.2. Development setup§

Install:

roc-parser does not pin a compiler. CI tests every change with the latest nightly, which it resolves at the start of each run, so install a new nightly when one is published. Check the one you have:

roc version

Output, with the date and commit of your nightly:

Roc compiler version nightly-2026-10-21-5349569

Download blueprint for your system from the release and check it against the release’s blueprint.sha256 before you run it. For an Apple silicon Mac:

gh release download 0.11.0 --repo lukewilliamboswell/roc-blueprint --pattern blueprint-aarch64-darwin --pattern blueprint.sha256
shasum -a 256 --check --ignore-missing blueprint.sha256
chmod +x blueprint-aarch64-darwin
mv blueprint-aarch64-darwin ~/.local/bin/blueprint

Blueprint.roc declares the tasks and what each needs. blueprint downloads the tools it pins in Blueprint.lock, such as Zig for the RocSyntax oracle, and expects the others, such as roc, python3, ruby and docker, on your PATH; blueprint requirements lists them. Only the benchmark environment needs Nix (Reproducible environment with Blueprint).

The scripts find the compiler through the ROC environment variable and fall back to roc on PATH. Leave ROC unset when you run blueprint, which reads it to choose the compiler that evaluates Blueprint.roc itself.

5.1.3. Repository map§

PathPurpose
package/The library: Parser, Utf8, and one module per format
examples/Small apps that use the package source (../package/main.roc); releases attach them pinned to the bundle URL
fuzz/Property-test targets, seed inputs and dictionaries (Property tests and conformance reviews)
scripts/Roc orchestration, independent Python oracle harnesses, and documentation and release-inventory helpers
scripts/<format>/Conformance probe, cases and known-failures baseline for each format
scripts/tests/Unit tests for the scripts, including the repository policy
tests/spec.jsonWhat each example prints, checked on every pull request and shipped with each release’s examples
docs/This manual, in AsciiDoc
docs/releases/Release notes, one AsciiDoc file per release
Blueprint.roc, Blueprint.lockThe development environments and the named tasks CI runs

5.1.4. Run the tests§

Each module carries its tests as expect blocks next to the code. Run them all through the package:

roc test package/main.roc

Output:

All (253) tests passed in 535.8 ms.

The number grows as tests are added.

5.1.4.1. Run what CI runs§

blueprint run check runs everything a pull request must pass, the same way the check job of ci.yml does. It takes a quarter of an hour or more on a laptop; its scratch files and evidence go under .roc-parser-tmp/:

blueprint run check

It runs scripts/check_all.roc, which works through these sections in order and stops at the first failure. Name sections to run only those, for example blueprint run check -- fuzz conformance, and list them with blueprint run check -- --list:

SectionWhat it runs
scriptsThe fixed tooling compiler version, Roc orchestration tests, checks and formatting, and the independent Python harness tests including repository policy
packagescripts/test_package.roc: roc check, roc fmt --check and roc test on the package, the compile-time literal checks and roc docs; then a release dry run, which bundles the package, builds the examples archive with the bundle’s release URL, and checks, builds and runs every case of its spec.json against that bundle served on a loopback address
examplesEvery example in examples/ against the working tree, compared with the output recorded in tests/spec.json
docsThe manual’s Roc examples and the API reference audit (Build the documentation)
fuzzBuilds every fuzz target and runs 2,000 inputs from its seeds (Property tests and conformance reviews)
conformanceThe fast conformance checks of every format against its reference implementation (Conformance)
asciidocAsciiDoc against the pinned Asciidoctor, and a smoke run of its performance harness
roc-syntaxRocSyntax against the compiler’s own parser, built with Zig, including recovery, mutations and the performance protocol
benchA smoke run of the roc-parser benchmark drivers (Benchmarks)

The longer runs are scheduled: fuzz.yml runs ten-minute campaigns of every fuzz target and every conformance review each night, and bench.yml runs the full benchmark comparison each week.

When you change what an example prints, record the new output and review the diff:

blueprint run examples -- --update

5.1.4.2. Repository policy§

scripts/tests/test_repository_policy.py enforces these rules. A pull request that breaks one fails CI:

scripts/tests/test_run_fuzz.py also requires every fuzz/*.roc target to be registered in scripts/run_fuzz.py, and the campaign matrix in fuzz.yml to match that registry; scripts/tests/test_check.py does the same for the conformance reviews in scripts/src/HarnessPlan.roc.

5.1.5. Build the documentation§

The manual is AsciiDoc under docs/. The docs task uses the shared native roc-docs renderer to build the HTML manual and searchable PDF, generates the API reference, and packages the combined site:

blueprint run docs

The site and release archives are under .docs-out/release/. To stamp a release version, pass -- --release-version V. The renderer and its fonts, source highlighting and diagram support are downloaded through Blueprint and checked against Blueprint.lock.

The API reference is generated from the doc comments in package/ with roc docs. Build it, or check that it builds without writing anything:

blueprint run api-docs -- --version unreleased
blueprint run api-docs -- --check

Code examples in the manual are compiled and run, so a changed API breaks the build rather than silently leaving a stale example:

blueprint run doc-examples

The docs section of blueprint run check runs the examples and the API check. Every pull request also builds the manual, HTML and PDF, in the dry run of the Release workflow; download the release-site artifact of that run to preview it.

When you write a chapter, follow the shared documentation guide: give each page one purpose (tutorial, how-to, reference or explanation), name the reader and the outcome at the top, label commands and their output separately, and put any warning before the step it applies to.

5.1.6. Before you open a pull request§

Pull requests need passing CI: check, which runs blueprint run check, and Build the release, the dry run of the Release workflow, as well as codeql. A new Roc nightly can break check without any fault in your change; see Compiler and nightly. Human review is encouraged; answer review comments before asking for another review after a substantial change.

5.1.7. License§

By contributing, you agree that your contribution is licensed under the Universal Permissive License v1.0.

5.1.8. Write documentation§

Write for a current user or contributor task. Remove superseded explanations and keep release changes with their release. Follow the shared documentation guidance.

5.2. Property tests and conformance reviews§

This chapter is for contributors who change a parser and need to know whether the change is correct. The first half explains how roc-parser checks its parsers beyond the expect tests: property tests driven by a fuzzer, and reviews against a mature reference implementation. The second half is a set of how-to sections: run the targets, reproduce and minimise a failure, turn it into a regression, and update a conformance baseline.

It assumes the setup in Contributing to roc-parser, including the latest Roc nightly, which CI uses too. Every command below uses that compiler:

export ROC=/path/to/roc_nightly/roc

5.2.1. Why property tests§

An expect test checks one input that somebody thought of. Parsers fail on the inputs nobody thought of: a carriage return on its own, a quote in the middle of a field, 100,000 nested brackets. A property test states something that must hold for every input, and a fuzzer searches for an input where it does not.

The fuzzer is roc-fuzz, a Roc platform that embeds libFuzzer. It is coverage guided: it keeps inputs that reach new code and mutates them further, so it finds deep paths far faster than random input would. The targets are library quality tests, not a security programme, and they run on every pull request and nightly in CI.

A failing property is a bug in the parser, or occasionally in the property. Either way the outcome is a fix: see Fix the cause, do not gate it.

5.2.2. Three kinds of target§

Each file fuzz/<target>.roc is one roc-fuzz app. They come in three kinds, which differ in what the fuzzer’s bytes mean and therefore in what the target can check.

Raw targets (xml-raw, yaml-raw, yaml-source, json-raw, json-options, csv-raw, http-raw, markdown-inline, markdown-document, string-primitives):: The bytes are the input document, often invalid UTF-8. Nothing is known about the expected result, so the target checks properties that hold for any document: the parser does not crash or hang, an error points inside the input, LF/CRLF/CR line endings give the same result, appending a final line break changes nothing, and the result survives re-serialisation. These are called metamorphic relations: they relate two runs of the parser instead of comparing one run with a known answer. Many raw targets also switch to a pathological generator (deep nesting, thousands of attributes, long delimiter runs) when the input starts with a marker byte, so that the per-input --timeout catches quadratic behaviour.

The yaml-source target checks the highlighting API separately from semantic parsing: token spans partition every original byte, diagnostics stay in range, key categories agree with roles, and repeated tokenization is deterministic. It reuses the YAML seed corpus and dictionary. Run a short check with python3 scripts/run_fuzz.py smoke yaml-source, or explore more locally with python3 scripts/run_fuzz.py campaign yaml-source --seconds 600.

Structured targets (csv-roundtrip, csv-decode, xml-roundtrip, xml-malformed, yaml-roundtrip, yaml-block, json-roundtrip, http-roundtrip, http-smuggling, markdown-inline-ast, markdown-blocks, markdown-refdefs, parser-combinators):: The bytes are choices. A generator reads them to build a value (a table, an XML tree, an HTTP message, a Markdown syntax tree) and a way to write it down, and builds the expected parse alongside the text from the specification, never by calling the parser. The target then requires the parser to return exactly that value. Mutating generators such as xml-malformed and http-smuggling instead inject one known violation into a valid document and require the parser to reject it. parser-combinators builds a random combinator expression and compares the real parser with a small reference interpreter of the documented semantics.

Seeded targets (csv, xml, yaml, http-request, http-response)

The original targets read a string through roc-fuzz’s Fuzz.str generator. They start from reviewable seeds in fuzz/seeds/<name>.json, which the runner encodes for Fuzz.str at run time, and use the token lists in fuzz/dictionaries/ to mutate towards real syntax. Seed strings are limited to 255 UTF-8 bytes by that encoding.

Raw targets also take seeds and a dictionary, written as plain text. Structured targets start from an empty corpus, because their bytes are not text.

The three JSON targets divide the work this way:

5.2.3. Oracles§

A property test can only be as good as its expected answer. Structured targets build that answer from the specification, but a generator can share a misunderstanding with the parser. So each format is also compared with a mature, independent implementation, its oracle, pinned to an exact version:

FormatOracleSpecification and suite
CSVPython’s csv module in strict modeRFC 4180, plus the documented dialect
XMLexpat (Python’s pyexpat)XML 1.0 5th edition, W3C XML Conformance Test Suite 2013-09-23
YAMLruamel.yamlYAML 1.2, the yaml-test-suite
JSONPython’s json module, with the parse options modelled in review_json.pyRFC 8259, JSONTestSuite, and pinned SPDX, CycloneDX, Syft and Trivy documents
HTTPh11RFC 9112 and RFC 9110; cases pin where RFC 9112 is stricter than h11
Markdowncmark-gfm 0.29.0.gfm.13 (via cmarkgfm), with markdown-it-py to adjudicateCommonMark 0.31.2 spec examples and GFM extensions

Oracles have bugs too. When roc-parser and the oracle disagree, the review reports it, and a person decides which one the specification supports. Known oracle quirks are named in the review scripts and in the baselines, with the section of the specification that settles them.

5.2.4. Run the property tests§

AsciiDoc adds asciidoc-raw for byte-input robustness and error locations, and asciidoc-structured for independently constructed block trees. Its broader oracle fixtures, generated corpus and mutation exploration are described in Reading AsciiDoc. Inline source agreement alone does not establish agreement on Asciidoctor substitutions or rendered HTML.

Run every target for a short, iteration-bounded smoke test. This is what the fuzz section of blueprint run check runs on a pull request; it builds up to four targets at once (--jobs) before running them in turn:

python3 scripts/run_fuzz.py smoke all

Run one or more targets by name, and lower the iteration count for a quick check while you work:

python3 scripts/run_fuzz.py smoke csv --runs 200

The last lines of the output are the target’s summary and its report:

csv: passed; baseline not_checked; examples 0; exploration passed (attempted 200, accepted 200, rejected 0)
Report: .roc-fuzz/reports/934809-521896414/report.json

Run a longer, time-bounded campaign on the target closest to your change. The scheduled fuzz.yml workflow runs a 600-second campaign for every target each night, on the latest Roc nightly:

python3 scripts/run_fuzz.py campaign markdown-inline-ast --seconds 600

--max-input-size, --timeout and --memory-limit override a target’s registered bounds, and --no-build reuses the binary from the last run.

The runner keeps everything under .roc-parser-tmp/fuzz/:

5.2.5. Reproduce and minimise a failure§

A failing run prints the crash message and the paths of its report and of the saved input, original-input.bin. In CI, the fuzz-failure-<target> artifact holds the binary, the corpus and the run directory. Use the same target name for every step.

  1. Show the input as the target decodes it. For a structured target this is the generated document and its expected value:

    python3 scripts/run_fuzz.py show csv-roundtrip INPUT --no-build
    
  2. Replay it to confirm that it still fails:

    python3 scripts/run_fuzz.py replay csv-roundtrip INPUT --no-build
    
  3. Minimise it into a smaller input that fails the same way:

    python3 scripts/run_fuzz.py minimize csv-roundtrip INPUT OUTPUT --no-build
    

INPUT can be any file, including one from the corpus. Showing a corpus entry of the seeded csv target prints the string it decodes to:

python3 scripts/run_fuzz.py show csv .roc-parser-tmp/fuzz/corpus/csv/13d7ab0a19cc0856931196f0e8bac548e045234b --no-build

Output (the last line):

"a"

Each command writes a report under a .roc-fuzz/ directory, which Git ignores.

5.2.6. Add a regression§

Once you understand the minimised input:

  1. Write the smallest expect in the module that fails for the same reason, and fix the parser until it passes. The expect is the regression; it runs in every build.

  2. For a seeded or raw target, add the readable input to fuzz/seeds/<name>.json so the fuzzer starts near the bug in future runs.

  3. If the generator of a structured target avoided the input’s shape, remove that restriction so the target covers it from now on.

  4. Keep the raw minimised input and the target binary outside the repository when exact reproduction of a historical failure matters.

5.2.7. Fix the cause, do not gate it§

When a property fails, change the parser, not the test. Do not add a condition to a target that skips the failing shape, a known-gaps flag, or a baseline entry, to make CI green. A gate hides the bug from every later run, and the fuzzer will not tell you when it comes back.

There are two legitimate exceptions, and both need a written reason:

Several gates that once existed were removed by fixing the parser, which is how the targets found the CSV final-record bug, the YAML block scalar chomping bugs and the quadratic Markdown inline cases.

5.2.8. Run a conformance review§

Each format has a review script that runs a small probe app, scripts/<format>/probe.roc, which prints the parser’s result as JSON, and compares it with the oracle. Install the pinned oracle into a virtual environment first:

python3 -m venv .venv
.venv/bin/python -m pip install --no-deps -r scripts/yaml/requirements.txt

scripts/http/, scripts/markdown/ and scripts/yaml/ each have a requirements.txt with every dependency pinned, so --no-deps installs exactly the reviewed versions; CSV, XML and JSON use the Python standard library. Then run the same checks as the Conformance jobs of the scheduled fuzz.yml workflow. scripts/check_conformance.roc -- does all of this in one environment: with no arguments it runs the fast checks that every pull request runs, and all adds the robustness campaigns:

.venv/bin/python scripts/review_yaml.py check --baseline scripts/yaml/known-failures.json
.venv/bin/python scripts/review_yaml.py campaign --smoke
python3 scripts/review_xml.py check --baseline scripts/xml/known-failures.json
python3 scripts/review_json.py check --baseline scripts/json/known-failures.json
python3 scripts/review_json.py campaign --smoke
python3 scripts/review_csv.py
.venv/bin/python scripts/review_http.py

The campaign commands are robustness campaigns. review_json.py campaign gives 60% of its time budget to libFuzzer runs of the three JSON targets, whose raw targets also start from every short case and suite input. It gives the rest to a seeded differential loop that generates documents and option sets, mutates them, and compares the probe with the oracle. It writes a report, and a reproducer for each kind of disagreement, under .roc-parser-tmp/json-review/campaign/. --smoke runs a short, iteration-bounded version, as CI does; for a longer local run, pass a budget and a seed, and replay a reproducer once you have a fix:

python3 scripts/review_json.py campaign --seconds 1800 --seed 94
python3 scripts/review_json.py replay .roc-parser-tmp/json-review/campaign/reproducers/FILE.json

The CSV review runs the hand-written cases, 2000 seeded random inputs and, with --corpus DIR, a fuzz corpus. Its last line summarises the run:

cases=2035 unexpected=0 known=0 fixed_known=0

The Markdown reviews compare against the CommonMark spec examples and cmark-gfm:

.venv/bin/python -m pip install --no-deps -r scripts/markdown/requirements.txt
.venv/bin/python scripts/review_markdown.py check
.venv/bin/python scripts/review_markdown_blocks.py check --baseline scripts/markdown/blocks/known-failures.json

Several scripts can also check a structured target’s generator against the oracle, which is how a generator’s own mistakes are caught: review_xml.py corpus <target> <corpus>, review_json.py corpus <target> <corpus>, review_http.py --corpus DIR --fuzz-show BINARY, review_markdown.py generated BINARY CORPUS and review_markdown_blocks.py crosscheck BINARY CORPUS.

5.2.9. Known-failures baselines§

Each format keeps a baseline at scripts/<format>/known-failures.json (for Markdown blocks, scripts/markdown/blocks/known-failures.json): the cases where roc-parser and the oracle are allowed to disagree. Every entry carries a reason, of one of these kinds:

A review fails when a case disagrees and is not in the baseline, and reports any baseline entry that now passes as stale. When your change makes a known failure pass, remove the entry. When it introduces a new disagreement, fix the parser; add an entry only for one of the reasons above. review_yaml.py, review_json.py and review_markdown_blocks.py can rewrite the baseline with --record-known-failures PATH; review the diff entry by entry before you commit it.

5.2.10. OpenSSF Scorecard§

The OpenSSF Scorecard Fuzzing check detects OSS-Fuzz, ClusterFuzzLite and a few language-specific frameworks, but not Roc targets. A low Fuzzing score is a detection gap, not evidence that these campaigns did not run.

5.3. Add a format module§

This how-to is for contributors who want to add a ready-made parser for a new text format, alongside CSV, Xml, Yaml, JsonValue, Markdown and HTTP. It assumes Contributing to roc-parser and Property tests and conformance reviews, and that you have opened an issue to agree that the format belongs in the package. When you finish, the new module has the same evidence behind it as the existing ones: tests, a review against a mature implementation, property tests in CI, benchmarks, and a chapter in this manual.

The steps use a hypothetical format called Toml as the example. Follow them in order: each step relies on the one before.

5.3.1. 1. Decide what you support§

Write down, before any code, which specification and version the module follows, and which parts of it the module supports. Every existing module supports a documented subset and rejects the rest with an error, rather than misreading it. For example Xml rejects a DOCTYPE, and Yaml rejects multi-line flow collections. A rejected input is a clear limit; a misread one is a bug that nobody notices.

Pick the oracle now too: a widely used implementation that you can pin to an exact version and call from Python, such as tomllib for TOML. The oracles of the existing formats are listed in Property tests and conformance reviews.

5.3.2. 2. Create the module§

  1. Add package/Toml.roc. Start it with a ## doc comment that names the specification, lists what is supported and what is rejected, and describes the error type. This comment becomes the module’s page in the API reference.

  2. Give every exposed value a ## doc comment with its purpose and, where it helps, a short expect example.

  3. Expose the module in package/main.roc and name it in that file’s doc comment. roc test package/main.roc, which scripts/test_package.roc runs, then runs its tests with the rest.

  4. Check that the name does not clash with a builtin module that Roc imports automatically. Roc has a builtin Json, so import parser.Json would leave the alias unbound; that is why the JSON module is JsonValue.

  5. Make it a type module and follow the conventions of the existing ones:

    • Toml.parse_str : Str -> Try(Toml, [InvalidToml(Toml.Error)]), where Toml.Error is a record with a message and the most precise location the format has (line and column for a text format);

    • Toml.parser : Parser(Utf8.Bytes, Toml) for callers who compose parsers, failing with ParseError({ message, offset });

    • Try(a, [Missing]) for every optional value, and methods in the .{ } block, called in method-call style in examples;

    • fallible parsing and validation functions return errors on every input; explicit constant assertions such as Literal.require are documented separately.

  6. If records are a natural target, also implement a format for type-directed decoding, as CSV.Format and Yaml.Format do, and expose Toml.decode with a Parseable where-clause alias. The parsers chapter of the Roc language reference lists the methods a format provides.

Prefer a scanner over the input bytes for document-level parsing, with an explicit stack for nesting. The combinator style is convenient for small grammars, but every rewritten module (CSV, XML, HTTP, Markdown) moved to scanners to stay linear in the input size and to avoid deep recursion, and JsonValue was written as one from the start.

If the format has common variants, or callers need checks beyond the specification, give the module a parse_with that takes a nominal options record with defaulted fields, as JsonValue.Options does. Make the defaults the strict specification, and make each relaxation and each extra check a field that is off by default, so parse_str(text) is parse_with(text, {}) and nobody gets a relaxation they did not ask for. Keep each option’s code at one check site, test every field at and past its boundary, and pass the same record through the validation props, so that compile-time and run-time checks agree.

Compile-time parsing is an enduring requirement for format modules. Make the document type nominal and implement from_quote by delegating to a checked validate function returning Try(Toml, [BadQuotedBytes(Str)]). Use a nominal props record with defaulted fields for the source name, format options, and configurable extra checks. Add validate_as when type-directed decoding is supported. Keep ordinary parsing acceptance unchanged. See Parse and validate during compilation for the quotation and imported-file contract.

5.3.3. 3. Write the tests§

Put expect tests next to the code they cover. For every rule, test an input that is accepted, one that is rejected, and the boundary between them. Add a test for each limit you chose in step 1.

Add compiler fixtures for valid and invalid quotations and imported text, including default checks and overrides, to scripts/literals/cases.json, and add import parser.Toml to the app header that app_source in scripts/check_literals.py writes. The script checks rejection by both roc check and roc build, and verifies the intended error message rather than accepting any compiler failure.

Run the package tests:

$ROC test package/Toml.roc
$ROC test package/main.roc

5.3.4. 4. Add a probe and a review script§

A conformance review runs the same inputs through your module and the oracle and compares the results. It needs three pieces under scripts/toml/, modelled on scripts/csv/, the smallest of the existing reviews:

probe.roc

A basic-cli app that reads one document on standard input and prints one JSON line: the parsed value or an error. Use the public API only.

cases.json

Hand-written cases with a name and an input, one for each rule and each known edge case. Add the specification’s own examples or test suite if there is one, pinned by URL and SHA-256 as scripts/review_xml.py does for the W3C suite, or vendored as described below.

known-failures.json

The baseline. It starts empty; every entry added later needs a reason (see Property tests and conformance reviews).

Then add scripts/review_toml.py. It builds the probe with $ROC (falling back to roc on PATH), runs every case through the probe and the oracle, converts both results to the same shape, and exits non-zero on any disagreement that is not in the baseline. Report baseline entries that now pass as stale. If the oracle is a third-party package, pin it, and all of its dependencies, in scripts/toml/requirements.txt so that pip install --no-deps -r installs exactly the reviewed versions.

To vendor a third-party test suite instead of downloading it, convert it to one file, such as scripts/toml/suite.json, that records the upstream repository and revision in a source object, and commit the suite’s licence beside it. scripts/yaml/suite.json with YAML-TEST-SUITE-LICENSE, and scripts/json/suite.json with JSONTESTSUITE-LICENSE, are the models. Check that the licence allows redistribution, and give the script a command that regenerates the file from a checkout, like review_json.py vendor-suite. Where the suite says an input is valid but your module rejects it by design, label the case a policy deviation with its reason, as review_json.py does for repeated keys, rather than hiding it.

Run it, fix the parser until it passes, and add a unit test under scripts/tests/ for any logic in the script that is not a direct call to the oracle.

5.3.5. 5. Add property-test targets§

Add at least two targets under fuzz/, as described in Property tests and conformance reviews:

toml-raw.roc

Raw bytes. Check that the parser never crashes, that errors point inside the input, and the metamorphic relations that hold for the format, such as line-ending equivalence. Bound pathological shapes with the runner’s --timeout.

toml-roundtrip.roc

A structured generator. Build a value and a way of writing it from the bytes, build the expected result from the specification without calling the parser, and require the parser to return exactly that value.

Check the generator against the oracle on a corpus before you trust it: add a corpus mode to the review script, like review_xml.py corpus.

Then add a robustness campaign to the review script: a campaign command, like review_yaml.py campaign and review_json.py campaign. It spends part of a time budget on libFuzzer runs of your targets and the rest on a seeded differential loop that generates and mutates documents, runs each through the probe and the oracle, and writes a report and a reproducer for every kind of disagreement under .roc-parser-tmp/. Give it --smoke for a short, iteration-bounded run in CI, --seconds and --seed for local runs, and a replay command for a saved reproducer.

5.3.6. 6. Register the targets§

  1. Add each target to TARGETS in scripts/run_fuzz.py, with its seed encoding (raw for raw bytes, none for a structured target), seeds, dictionary, input size and timeout.

  2. Add reviewable seeds in fuzz/seeds/toml.json and syntax tokens in fuzz/dictionaries/toml.dict for the raw target.

  3. Add each target to the target matrix of the fuzz job in .github/workflows/fuzz.yml, in the same order as in TARGETS, which TARGET_ORDER in scripts/run_fuzz.py follows. Register the review in reviews in scripts/src/HarnessPlan.roc, with its command and requirements file, and the campaign’s --smoke run as a second, scheduled entry, as json and json-robustness do; then add both to the review matrix of the conformance job in fuzz.yml, in the same order.

scripts/tests/test_run_fuzz.py fails if a fuzz/*.roc file is not registered, or if the workflow matrix lists the targets differently, or in a different order, from the registry; scripts/tests/test_check.py does the same for the reviews. Every pull request runs the new targets and the fast review through blueprint run check. Check the registries and run the new targets:

python3 -m unittest discover -s scripts/tests -p "test_*.py"
python3 scripts/run_fuzz.py smoke toml-raw toml-roundtrip

5.3.7. 7. Add a benchmark§

Follow "Add a benchmark" in Benchmarks: add bench/roc/toml.roc, which calls the public entry point through Bench.run!, a branch in bench/roc/alloc.roc, a deterministic generator and pathological entries in scripts/bench.py, a frozen sample, and at least one comparator per language, pinned exactly. scripts/tests/test_bench.py fails until the format has all of them. Check that the pathological documents scale linearly; the allocation counts from --allocations are a steadier signal than the timings:

python3 scripts/bench.py --quick --formats toml
python3 -m unittest discover -s scripts/tests -p "test_bench.py"

5.3.8. 8. Document the module§

  1. Add a chapter docs/toml.adoc that shows how to parse a document, how to handle errors, and what is and is not supported. Include it from the manual’s index with leveloffset=+1, like the other format chapters.

  2. Make every code example in the chapter a complete program that scripts/check_doc_examples.roc compiles and runs, so it cannot drift from the API.

  3. Add the module wherever the manual lists the formats: the format table in README.md, the lists in docs/index.adoc, and modules.adoc, overview.adoc, errors.adoc, compile-time-parsing.adoc, conformance.adoc, property-testing.adoc and performance.adoc.

  4. Add the module to the release notes for the next version in docs/releases/.

Build and check the documentation:

blueprint run api-docs
blueprint run doc-examples
blueprint run docs

5.3.9. Checklist§

5.4. Benchmarks§

This chapter is for contributors who want to measure a change, add a benchmark or reproduce the figures in Performance. It covers the harness, the corpora, the comparison programs, the environments the benchmarks run in, and how to read a report.

5.4.1. Run the benchmarks§

You need a recent Roc nightly; the report records which one. Go, Rust (cargo) and Python 3 are optional: a missing toolchain is skipped and named in the report.

$ export ROC=~/roc_nightly-macos_apple_silicon-2026-10-21-5349569/roc
$ python3 scripts/bench.py --quick             # about two minutes
$ python3 scripts/bench.py --allocations       # the full comparison

--quick measures each format’s samples, its small generated document and one pathological document, with three short batches. Use it to check that nothing is broken, not to compare numbers. The full run takes about half an hour on a laptop.

Narrow a run with these options:

--formats csv,xml

Only these formats.

--langs roc,go

Only these implementations (roc, rust, go, python).

--only large,sample-svg

Only documents with these names or kinds (sample, small, medium, large, pathological).

--label before

Write the report to .roc-parser-tmp/bench/before/, so two runs can be compared side by side.

--repetitions, --target-ms, --timeout

Batch count, approximate batch duration and the time limit for one batch.

--corpus-only

Write the corpus and its manifest, then stop.

To compare two revisions of the parser, check each one out in turn and run the harness with a different --label; the report records the commit and whether the tree was dirty.

5.4.2. How a measurement works§

Every implementation, in Roc or in another language, is a small program that follows one protocol. It reads a document from standard input, starts a monotonic clock (Roc’s is the UTC clock, so batches must be long), parses the document a given number of times, stops the clock, and prints one JSON line:

{"iterations":120,"elapsed_ns":20414000,"successes":120,"checksum":43811}

Process start-up, reading the input and printing are outside the timed region. Each parse builds the implementation’s in-memory result and then folds over it into the checksum, so the work cannot be optimised away and every implementation pays for the structure it returns.

For each document and implementation, scripts/bench.py:

  1. runs one parse to estimate its cost, and picks the number of parses per batch so a batch takes about --target-ms (200 ms, or 20 ms with --quick);

  2. runs one warmup batch and discards it;

  3. runs --repetitions batches (7, or 3 with --quick), each in a fresh process;

  4. records the median time per parse, the minimum, maximum, standard deviation and spread ((max - min) / median), throughput in MB/s (1,000,000 bytes per second, from the median), and the peak resident set size of the batch processes;

  5. with --allocations, replays the document once through bench/roc/alloc.roc, a diagnostic built with roc build --fuzz, and records how many times the Roc parser called roc_alloc for it, using the target’s measure command.

Peak RSS is the whole process, so it includes the language runtime: about 1.5 MiB for Roc and Rust, 12–20 MiB for Go and Python, before any document is read. Compare it within a language or across document sizes, not as an absolute cost of a parser.

5.4.3. Corpora§

The corpus is generated by build_corpus in scripts/bench.py every run. Each generated document uses a random number generator seeded from its own name, so the bytes, and the SHA-256 recorded for each one in the report, are identical on every machine and run. Changing a generator changes the hashes, which marks results as not comparable with earlier ones.

Each format has five kinds of document:

sample

Small real-world-shaped files in bench/corpus/samples/: a CSV order export, this repository’s fuzz workflow (YAML), a design-tool-style SVG, a CycloneDX 1.6 software bill of materials (JSON), this repository’s README, and an HTTP browser request and JSON API response (kept in HTTP_SAMPLES so their CRLFs survive). They are frozen copies; do not update them.

small, medium, large

Generated documents of about 4 KiB, 64 KiB and 1 MiB in the shape of typical data: CSV rows with quoted fields and embedded newlines, YAML service configuration with flow collections and block scalars, an XML catalogue with attributes and entities, a CycloneDX-style JSON bill of materials whose components carry licences, nested properties, integers beyond 64 bits, long decimals and escaped or non-ASCII strings, a Markdown document with headings, lists, code, quotes and tables, and HTTP POST requests with a JSON body and chunked HTML responses.

pathological

Inputs that stress one mechanism: very wide CSV rows and long runs of escaped quotes, YAML nesting at the 100-level limit and long flow sequences, deeply nested XML and thousands of attributes, JSON arrays nested to the 128-level limit, a 200,000-number array, objects with 20,000 keys or 20,000 empty-array members, strings full of escapes or surrogate pairs and a 100,000-digit number, unclosed Markdown emphasis and nested brackets, and HTTP messages with many headers or thousands of one-byte chunks. Several of these were super-linear before the fuzzing work described in Bound work on untrusted input.

The corpus and a manifest.json with every document’s size and hash are written to .roc-parser-tmp/bench/<label>/corpus/, so you can feed a document to a driver by hand:

$ .roc-parser-tmp/bench/full/bin/roc-yaml 100 < .roc-parser-tmp/bench/full/corpus/yaml/generated-medium.yaml

5.4.4. Roc drivers§

bench/roc/ holds one driver per format, each a few lines calling the format’s public entry point: CSV.parse_with with Parser.many(CSV.field(CSV.string)), Yaml.parse_str, Xml.parse_str, JsonValue.parse_str, Markdown.parse_str, and HTTP.parse_request or HTTP.parse_response. The timing loop is shared in bench/roc/Bench.roc. The harness builds each driver with roc build --opt=speed; never benchmark roc dev or an unoptimised build, which is many times slower.

When an entry point is renamed, only the call in that format’s driver (and the matching line in alloc.roc) changes.

5.4.5. Comparison programs§

LanguageWhereLibraries, pinned by
Rustbench/compare/rust/csv, yaml-rust2, serde_yaml, quick-xml, roxmltree, serde_json, pulldown-cmark, comrak and httparse, pinned exactly in Cargo.toml and Cargo.lock; built with cargo build --release --locked, thin LTO and one codegen unit.
Gobench/compare/go/encoding/csv, gopkg.in/yaml.v3, encoding/xml, encoding/json, goldmark and net/http, pinned by go.mod and go.sum.
Pythonscripts/bench_python.py and bench/compare/python/requirements.txtcsv, PyYAML (pure Python and libyaml), xml.etree, json, markdown-it-py and h11. The program is under scripts/ because repository policy keeps every Python file there. The harness installs the pins into a virtual environment in ~/.cache/roc-parser/bench-venv (outside the checkout, for the same reason); pass --python to use another interpreter.

Each program builds the same kind of in-memory structure as roc-parser:

These choices, and where they still differ, are listed in Compare equivalent work. Read them before quoting a ratio.

To add a comparator, add a function to the language’s program, register its name in COMPARATORS in scripts/bench.py with a one-line description of what it measures, pin the library, and run --quick --langs <lang> to check that it accepts the samples.

5.4.6. Add a benchmark§

scripts/tests/test_bench.py checks that the corpus is deterministic, that every format has every kind of document, and that each format has a driver and comparators; run it with python3 -m unittest discover -s scripts/tests.

5.4.7. Read a report§

Each run writes three things to .roc-parser-tmp/bench/<label>/:

results.json

Metadata (date, machine and CPU, OS, Roc, Go, Rust and Python versions, every crate, module and package version, the commit and whether the tree was dirty, the corpus manifest with hashes, and what each implementation measures), one record per document and implementation, and the comparison table.

report.md

The same as Markdown tables.

logs/

Build output and allocation diagnostics.

The comparison table gives, per format and library, the geometric mean over the non-pathological documents that both implementations accepted of the ratio of median times. "roc-parser is 3x slower" means the library parsed those documents three times as fast. Pathological documents are left out of the mean because they measure worst cases, which differ by design between parsers; read them individually.

In the per-document table, check these before trusting a number:

5.4.8. Continuous integration§

.github/workflows/bench.yml runs the full comparison with --allocations, through Blueprint (Reproducible environment with Blueprint), on ubuntu-latest every Monday and on demand (Actions → Benchmarks → Run workflow), with the latest Roc nightly. It publishes report.md as the job summary and uploads the reports as the bench-<run id> artifact for 90 days. GitHub’s shared runners vary by a few tens of percent between runs, so compare ratios within one run rather than absolute times across runs.

Every pull request runs --quick --langs roc --fail-on-error as the bench section of blueprint run check (see Contributing to roc-parser). It fails only when a Roc driver does not build, crashes, or exceeds its 60-second batch limit, which catches a parser that has turned quadratic without gating on noisy timings.

5.4.9. Reproducible environment with Blueprint§

Blueprint.roc at the repository root describes the bench environment for roc-blueprint 0.6.0: Go, Rust (cargo and rustc), Python and Git from Nix, at the nixpkgs revision the committed Blueprint.lock records, and Roc from your machine. It declares x86_64-linux (CI) and aarch64-darwin (Apple silicon Macs), and needs Nix with flakes enabled. Install the blueprint binary as Contributing to roc-parser describes, put the latest Roc nightly on PATH as roc, and run:

$ blueprint run bench-quick              # small corpus
$ blueprint run bench                    # full comparison, with --allocations
$ blueprint run bench -- --only csv      # extra arguments go to scripts/bench.py
$ blueprint shell bench                  # every toolchain on PATH
$ blueprint update                       # refresh Blueprint.lock

The first run downloads the toolchains; later runs start in a few seconds. Generated files go to .blueprint/, which is not committed. Leave ROC unset when you run blueprint: Blueprint reads ROC to choose the compiler that evaluates Blueprint.roc itself.

Compiler. Roc is not pinned. The tasks use roc from your PATH, which in CI is the latest nightly, so a benchmark run measures the compiler the package is tested with that day. The report names it.

Go, Rust and Python follow the nixpkgs revision in Blueprint.lock. Library versions are pinned by Cargo.lock, go.sum and requirements.txt as above, so a Blueprint run and a plain run measure the same library code. The Python packages go into one virtual environment per interpreter, under ~/.cache/roc-parser/.

Provenance. The bench and bench-quick tasks pass --blueprint to scripts/bench.py. The report’s Environment line, and metadata.environment in results.json, then record the SHA-256 of Blueprint.lock and the nixpkgs revision it pins; a run without the flag says outside blueprint. Both record the path of each toolchain, so you can check that it came from /nix/store.

CI. The benchmark workflow installs Blueprint and Nix with roc-automation’s setup-blueprint action and runs blueprint run bench, so CI and a Mac use the same pins.

Without Nix, install the toolchains yourself and run scripts/bench.py as shown in Benchmarks; the report records every version either way.

5.4.10. Profile one workload§

For a closer look at one parser, build its driver with debug information and sample it. On macOS, samply works on the optimised binaries:

$ "$ROC" build --opt=speed --debug bench/roc/yaml.roc --output=.roc-parser-tmp/yaml-prof
$ samply record .roc-parser-tmp/yaml-prof 1000 < .roc-parser-tmp/bench/full/corpus/yaml/generated-medium.yaml

Some Roc functions keep hashed linker names even with --debug; nm -n maps recorded offsets back to symbols.

scripts/bench_yaml.py is an older, YAML-only harness kept for comparing two revisions of the YAML parser with --replace-dep (--parser-root <checkout>/package) on the YAML microbenchmark fixtures, and for its allocation counter, fuzz/yaml-alloc.roc. Its reports go to .roc-parser-tmp/yaml-bench/<label>/.

5.4.11. Roc syntax reference runtime comparison§

Run scripts/compare_roc_syntax_performance.py locally to compare the pure Roc parser with the pinned compiler parser on each identical input. Build the oracle with scripts/roc_syntax/build_oracle.py --optimize ReleaseFast first, then pass its executable through --oracle-command and the ordinary Roc syntax correctness worker through --probe-command. The comparison builds its own Roc driver using --opt=speed; --candidate-binary explicitly reuses an existing optimized driver and marks its build provenance unverified in the report.

python3 scripts/compare_roc_syntax_performance.py \
  --roc /path/to/roc \
  --probe-command '/path/to/roc scripts/roc_syntax/probe.roc' \
  --oracle-command .roc-parser-tmp/syntax-oracle/install/bin/roc-syntax-oracle

The default local run covers 33 geometric and document inputs. Repeat --fixtures path.json to include selected curated or extracted corpora, retaining each mode. Every input must first agree on acceptance and complete consumption; accepted inputs must also agree on their normalized tree. The oracle bounds its projection depth at 256; documented depth cutoffs match the corresponding candidate subtree and are counted explicitly in the report. Those inputs are labelled partial_depth_limited, not complete structural validation; this local timing eligibility rule does not relax the ordinary grammar gates. Unknown grammar variants remain errors. Timing excludes JSON, source encoding, startup and I/O. Each parser repeatedly tokenizes, parses, and releases its result while observing only constant-time result counts. Neither parser performs a full AST traversal inside this timing lane; the existing geometric benchmark remains separate.

Batches calibrate to at least 20 milliseconds, warm up, and collect five samples. Reports retain every sample, source hash, build receipt, median ratio and a conservative ratio lower bound (fastest Roc sample / slowest reference sample). A one-microsecond reference floor avoids division by zero and exaggerated tiny input ratios. The default --max-reference-ratio 1.5 requires runtime within 1.5 times the optimized reference on every input. Exceeding its lower-bound ratio returns a nonzero exit and saves the exact request and timings under failures/ for replay. On 19 October 2026 all 33 inputs passed the 1.5 lower-bound ratio on a loaded host (load average about 11). Medians on that host still exceeded 1.5 for some adjacent-statement and unclosed inputs, so compare retired instructions when the host is busy: those families run 1.1 to 1.35 times the reference’s instructions. Use --replay failures/<hash>-<mode>.json to time a saved input again.

--smoke selects only two tiny inputs and one one-millisecond sample, checks the protocol and correctness, and disables ratio assertions. This is the intended CI freshness check. Broad runtime ratchets remain an explicit local activity because shared CI timing is noisy. The smoke also permits a verified ReleaseSafe oracle; a full ratio comparison requires a ReleaseFast build receipt matching the binary hash and immutable compiler revision.

5.5. Compiler, releases, settings and security reports§

This chapter is for maintainers with write access to the repository. It covers which Roc compiler the repository uses, how a release is cut and what it publishes, the repository settings the workflows rely on, and how security reports are handled. It assumes Contributing to roc-parser.

roc-parser follows the roc-automation model for Roc packages, with the deviations listed in Repository tooling. The model’s manual, at the revision this repository uses, explains the reasons behind each rule: the three workflows, optional workflows and repository settings.

5.5.1. Compiler and nightly§

Roc has no stable release yet, and roc-parser does not pin a nightly. Every workflow resolves the latest release of roc-lang/nightlies once per run and passes that tag to roc-lang/setup-roc, so every job of a run uses the same compiler, and every run uses the newest one:

There is no compiler pin file and no automated update controller: the compiler moves by itself every day, and nothing else in the repository changes because of it. No Roc file in the repository carries a roc header field.

5.5.1.1. Nightly compatibility§

nightly.yml calls roc-automation’s shared nightly compatibility workflow every day at 13:17 UTC. It downloads the examples archive of the latest final release, checks, builds and runs every case of its spec.json on the latest nightly, and keeps one tracking issue: opened by the first failure, commented on by later ones, closed by the next pass. A failure means the published release needs a patch release that works with the new compiler.

A run can be dispatched by hand with a nightly-tag and a release-tag to reproduce a past combination; such a run never opens or closes the issue.

5.5.1.2. The tools that do not move§

These change only through a pull request a maintainer reviews:

5.5.2. Repository tooling§

The check, package, bundle, documentation-example and release drivers are executable Roc scripts under scripts/, run with the fixed roc-stable compiler fetched by Blueprint. Package commands use the separate roc on PATH. The released release-package utility from tools-0.10.0 builds the bundle, examples archive, API archive and notes, and tests archived examples against the exact bundle. The shared release workflow composes it with the manual and the bundle’s SBOM. These local commands publish nothing. Independent Python, Ruby and Zig oracle harnesses compare parser behavior. scripts/automation-policy.json lists every runtime exception and each probe or compiler fixture that must use the floating nightly. The policy check rejects unlisted files, stale exceptions, tooling pin drift and workflow pin drift. The shared working-tree and archive checks both use tests/spec.json.

The manual builds as HTML and PDF with roc-docs from tools-0.10.0. Blueprint fetches the native binary and checks its digest against Blueprint.lock. Use the documentation commands in Contributing to roc-parser to reproduce the build.

Write upcoming release changes in docs/releases/unreleased.adoc; rename that file to the chosen version when preparing its release record.

5.5.3. Release a new version§

Releases follow semantic versioning: a release that changes the result of a public function on any input, or removes or changes a public signature, is a major release.

5.5.3.1. Before you start§

  1. Confirm that main is green: CI, and the Release dry run of the last pull request.

  2. Choose the version with roc bump, which compares the public API with the latest release and says how far the version must move:

    roc bump --old https://github.com/lukewilliamboswell/roc-parser/releases/download/<latest>/<hash>.tar.zst package/main.roc
    

    The shared release gate accepts a requested major greater than the latest final major before API extraction: every possible API change fits that bump. For a same-major release, it runs roc bump --expect <version> and stops if the comparison fails or understates the change. Compilation, the full package checks and exact release-candidate tests remain required for every release. The build allows 75 minutes so the full checks complete before assembly.

  3. Write the release notes as docs/releases/<version>.adoc, usually by renaming docs/releases/unreleased.adoc, and link them from docs/releases/index.adoc. Put the upgrade impact first. Show how to depend on the release with the placeholder {package-url}, which the workflow replaces with the bundle’s URL; notes that do not use it are refused. Use headings, paragraphs, lists, [source] blocks, simple tables and links: the notes become the GitHub release description as Markdown, and other markup is refused.

  4. Merge the notes through a normal pull request. Its Release dry run renders the notes of the highest version under docs/releases/, which are these.

5.5.3.2. Run the release§

Run the Release workflow (release.yml) from the Actions tab on main, with version set to the new version, for example 2.1.0, or 2.1.0-rc.4 for a release candidate. The pinned roc-automation release-package.yml workflow validates the version, runs the checks, builds the package and tests the unchanged release candidate. It owns SBOM validation, attestations and publication. Its publishing jobs run no repository code.

The sbom-composition task accounts for package source files and the reviewed Unicode inputs in scripts/release/dependencies.json. It checks archive members against checkout SHA-256 digests; the shared tools validate the graph and bind the sidecars to the actual bundle.

Every pull request runs the shared release workflow with 0.0.1-dryrun and publishes nothing. A separate read-only job builds the manual and unmodified compiler API documentation using the release’s resolved nightly. A final release deploys that site to GitHub Pages.

5.5.3.3. What a release contains§

AssetContents
<hash>.tar.zstThe package bundle that users name in their app header.
roc-parser-examples-<version>.zipThe examples, with their parser dependency pinned to the bundle’s URL and nothing else changed, a README, and spec.json: what each example prints, from tests/spec.json, in the form roc-automation’s nightly compatibility runner reads.
roc-parser-docs-<version>.zipThe unmodified roc docs API reference, under the version directory. The manual is a separate PDF asset; GitHub Pages serves the manual and API reference together for the latest final release.
roc-parser-<version>.pdfThe manual as one PDF.
<hash>.tar.zst.spdx.json, <hash>.tar.zst.spdx-2.3.json, <hash>.tar.zst.cdx.jsonThe source inventory in SPDX 3.0.1, SPDX 2.3 and CycloneDX 1.7 formats, bound to the bundle’s SHA-256 and accounting for its source and dependencies.
<hash>.tar.zst.sbom-manifest.jsonThe manifest binding the bundle and its SPDX 3.0.1 and CycloneDX sidecars by SHA-256.
LICENSEThe package license.

GitHub stores build-provenance attestations for the release assets.

Nothing in the repository changes after a release. The examples in examples/ always use the package source (parser: "../package/main.roc"); only the archive attached to the release points them at its URL.

5.5.3.4. After the release§

  1. Check the release page: the notes, with the bundle URL and the compilers, and every asset above.

  2. Verify the provenance of the bundle you downloaded:

    gh attestation verify <hash>.tar.zst --repo lukewilliamboswell/roc-parser
    
    # Download the attestation for offline use.
    gh attestation download <hash>.tar.zst --repo lukewilliamboswell/roc-parser
    
  3. For a final release, open the published manual and API reference and check that they show the new version, and dispatch Nightly compatibility to test the new release on the latest nightly.

If publication stops part way, follow Recover an interrupted publication. Never publish a draft by hand or replace an asset; changed bytes are a new version.

5.5.4. Repository settings§

The workflows rely on these settings, which a maintainer applies on GitHub; roc-automation’s check-settings task, run from a checkout of roc-automation, reports which are in place:

5.5.5. Security reports§

The policy is in SECURITY.md: security fixes are made for main and the latest release, and reports arrive privately through GitHub private vulnerability reporting, never in public issues.

When a report arrives:

  1. Acknowledge it within 7 calendar days and give an initial assessment within 14.

  2. Reproduce it with the reporter’s input. If it is a crash, hang or excessive resource use, add the input to the matching fuzz target’s seeds or the module’s tests in the private fix branch, as described in Property tests and conformance reviews.

  3. Prepare the fix in the private security advisory’s temporary fork, and agree a disclosure date with the reporter. The target is coordinated disclosure within 90 days.

  4. Release the fix as a new version, then publish the advisory, crediting the reporter if they agree.

6. Background§

6.1. Where the design comes from§

roc-parser is not a new idea. Parser combinators have been studied and used for fifty years, and almost every choice in the Parser and Utf8 modules repeats, adapts or deliberately declines something from that history. This chapter traces those ideas to their sources, so you can see why the library behaves as it does and where it differs from the libraries you may already know. It assumes you have read Your first parser; you do not need it to use the package.

The examples come from background-lineage.roc; every expect in it passes.

6.1.1. A parser is a function§

The founding idea is that a parser is an ordinary function: it takes the input, reads something from the front of it, and returns what it read together with the input that is left. Because parsers are values, functions that take parsers and return new parsers (the combinators) can express sequencing, choice and repetition directly, so the parser has the shape of the grammar it reads.

William Burge described such a set of combinators in his 1975 book Recursive Programming Techniques. Philip Wadler’s 1985 paper "How to replace failure by a list of successes" showed how a lazy functional language can represent a parser’s possible outcomes as a list: an empty list is failure, and several elements are the several ways the input could be read. Graham Hutton’s 1992 paper "Higher-order functions for parsing" turned these ideas into a complete, teachable library and became the standard reference.

roc-parser keeps the function and drops the list. A Parser(input, a) wraps a function from input to a Try holding either the value and the rest of the input, { value, rest }, or a ParseError with a message and an offset. Parser.custom takes such a function directly, so you can always step outside the combinators, and Parser.run runs a parser and returns the same shape:

# A parser is a function from input to a result and the rest of the input.
# `custom` wraps such a function directly.
take : U64 -> Parser(Utf8.Bytes, Utf8.Bytes)
take = |n|
	Parser.custom(
		|input|
			if input.len() >= n {
				Ok({ value: input.take_first(n), rest: input.drop_first(n) })
			} else {
				Err(ParseError({ message: "expected ${n.to_str()} more bytes", offset: 0 }))
			},
	)

# Monadic style: the next parser depends on a value already read. Here a
# length prefix says how many bytes follow.
length_prefix : Parser(Utf8.Bytes, U64)
length_prefix = Utf8.digits.skip(Utf8.codeunit(':'))

counted : Parser(Utf8.Bytes, Str)
counted = length_prefix.and_then(take).map(Str.from_utf8_lossy)

expect Utf8.parse_str(counted, "3:abc") == Ok("abc")
expect Utf8.parse_str(counted, "3:ab").is_err()

A parser returns one result, not a list. Wadler’s list of successes allows ambiguous grammars, where one input has several readings, but the formats this package reads are designed to have exactly one reading. A single result is cheaper, and it gives one clear place to put an error.

6.1.2. Sequencing: monads and applicatives§

Running one parser and then another needs a way to combine their results. Hutton and Erik Meijer’s "Monadic parser combinators" (1996) and the shorter "Monadic parsing in Haskell" (1998) observed that parsers form a monad: the sequencing operation, usually called bind or and_then, passes the value from the first parser to a function that chooses the second. That is what counted above does with and_then: the length it reads decides how many bytes the next parser takes.

Most of a grammar does not need that power. In "Applicative programming with effects" (2008), Conor McBride and Ross Paterson named the weaker applicative interface: a pure value lifted into the parser, and a way to apply a parser of functions to a parser of arguments. Their paper points to parsing as a case where monads are more than you need, citing S. Doaitse Swierstra and Luc Duponcheel’s 1996 combinators, whose structure is known before any input is read. Applicative parsers cannot make the grammar depend on earlier values, and in return they read as a description of the input.

Parser.const with keep and skip is exactly that applicative interface, with the names Evan Czaplicki’s elm/parser gave its pipeline operators: |= keeps a result and |. ignores one. You list the pieces of the input in order, and the curried constructor receives the ones you keep:

# Applicative style: a constructor function, then one `keep` per field and
# one `skip` per piece of punctuation. No step depends on an earlier value.
Point : { x : U64, y : U64 }

point : Parser(Utf8.Bytes, Point)
point =
	Parser.const(|x| |y| { x, y })
		.skip(Utf8.codeunit('('))
		.keep(Utf8.digits)
		.skip(Utf8.codeunit(','))
		.keep(Utf8.digits)
		.skip(Utf8.codeunit(')'))

expect Utf8.parse_str(point, "(3,4)") == Ok({ x: 3, y: 4 })

roc-parser builds map and keep on this same application step, and also offers the monadic and_then method for the cases that need it, such as counted. Prefer keep and skip when the grammar allows: a parser that uses only them can be read top to bottom as a description of its input.

6.1.3. Choice and backtracking§

A choice between alternatives must decide what happens when the first one fails after reading part of the input. The libraries in this lineage give three answers.

Full backtracking

The next alternative starts again from the original input. Wadler’s lists and Hutton’s combinators work this way, and so does Bryan O’Sullivan’s attoparsec, which keeps the whole input so that it can backtrack arbitrarily.

Committed choice

Daan Leijen and Erik Meijer’s Parsec (2001) tries the second alternative only if the first failed without consuming input. Their paper gives two reasons: naive backtracking holds on to the input and leaks space, and when the first alternative has already read part of the input, its failure is usually the error worth reporting. When you do want to backtrack, you wrap the first alternative in try. Elm’s parser takes the same default for the same reason, with backtrackable in place of try; its README uses the input [ 1, 23zm5, 3 ], where the useful error is at the z, not at the [.

Ordered choice

Bryan Ford’s parsing expression grammars (PEGs, 2004) define choice as prioritized: A / B tries A, and tries B from the same position only if A fails. Once A succeeds, B is never considered, even if the rest of the parse then fails.

roc-parser’s Parser.alt and Parser.one_of are PEG ordered choice. A failed alternative is always retried from the original input, so there is no try, and the first success wins:

# Ordered choice, as in a PEG: the first alternative that succeeds wins, and
# a failed alternative is retried from the same input.
sign : Parser(Utf8.Bytes, [Plus, Minus, PlusPlus])
sign =
	Parser.one_of([
		Parser.const(PlusPlus).skip(Utf8.string("++")),
		Parser.const(Plus).skip(Utf8.string("+")),
		Parser.const(Minus).skip(Utf8.string("-")),
	])

expect Utf8.parse_str(sign, "++") == Ok(PlusPlus)
expect Utf8.parse_str(sign, "-") == Ok(Minus)

The trade-offs follow from the history. Without a commit point, you never have to decide where to put try, and a grammar reads the same as its PEG. The costs are the ones Parsec was designed to avoid: a grammar whose alternatives share long prefixes re-reads them, and without help a failure deep inside an early alternative would be replaced by the failures of the later ones. The furthest-failure rule described below provides that help, and Combinators by task shows how to order alternatives so that re-reading rarely matters.

6.1.4. Repetition that always ends§

PEG repetition is greedy: e* matches as many e as it can and never gives any back. Parser.many, one_or_more and sep_by behave the same way, which is why a failed element ends the repetition rather than the parse.

Repeating a parser that can succeed without reading anything, such as chomp_while or maybe(p), would loop forever. Parsec refuses at run time, with the error "combinator 'many' is applied to a parser that accepts an empty string". roc-parser instead stops the repetition at the first element that makes no progress, and returns what it has collected. The check compares input lengths, so it costs O(1) per element and needs only the len method from the input type, which the where clause on many states. A Roc program should not crash on its input, so a well-defined result is better than an error that only appears for some inputs.

6.1.5. Error reporting§

The early papers say little about errors. Parsec made them a design goal: on failure it reports the position and the set of things that would have been accepted there. Mark Karpov’s megaparsec, a fork of Parsec, keeps that and, when it merges the errors of two branches, prefers the one that got further into the input. Elm’s parser adds context: a parser can say "I am reading a list", so the error can name what was being read and not just the row and column, which the Elm compiler uses for its error messages.

roc-parser adopts megaparsec’s rule. Parsers track the furthest failure, the failure that read furthest into the input, and every runner reports it as ParseError({ message, offset }), where offset is a byte offset. A repetition that stops at a bad element remembers that element’s failure, so if the parse then fails, the error points at the bad element rather than at the input left over after the repetition:

# Furthest failure: `many` stops at the bad second point, but the error the
# runner reports is that point's own failure, at the byte where it happened.
points : Parser(Utf8.Bytes, List(Point))
points = Parser.many(point)

expect Utf8.parse_str(points, "(1,2)(3,x)") == Err(ParseError({ message: "Not a digit", offset: 8 }))

The alternative that got furthest is almost always the one the author of the input meant, so this recovers most of the precision of committed choice while keeping ordered choice. ParseError is an open tag union, so ? passes it through a function that returns other errors too.

The format modules go further on their own: Yaml and Xml report line and column, CSV the record and field, and HTTP the byte offset, each in an error tag named after the format. Report parse errors shows how to present them.

6.1.6. Bytes, not characters§

Haskell’s early combinator libraries read lists of characters. The libraries built for speed read bytes: attoparsec has a ByteString interface, and Geoffroy Couprie’s nom for Rust describes itself as "byte-oriented" and "zero-copy". The Roc language reference gives the same advice for Roc: in the strings chapter of the language reference, it recommends working in UTF-8 bytes when implementing a parser.

So the input type of the text parsers, Utf8.Bytes, is List(U8), and 'a' is a byte. Every format in this package is defined over ASCII delimiters, which in UTF-8 are single bytes that never appear inside a multi-byte character, so reading bytes is both correct and fast. Text is turned back into Str only once a whole field has been read.

6.1.7. Recursion and laziness§

A grammar for nested data refers to itself: a list contains values, and a value can be a list. In Haskell this costs nothing, because laziness delays building a parser until it is used. Roc evaluates eagerly, so a parser that refers to itself would be built forever. Parser.lazy takes a function that builds the parser and calls it only when the parser runs, exactly as elm/parser’s lazy : (() -> Parser a) -> Parser a does for eager Elm. Combinators by task shows it in use.

Left recursion, where a rule starts with itself (expr = expr "-" term), is a different problem. A top-down parser calls the rule again before reading anything and never stops. Ford’s PEG paper notes that left recursion is not available in PEGs. Research has since shown how to support it in combinators, for example Richard Frost, Rahmatullah Hafiz and Paul Callaghan’s 2008 "Parser combinators for ambiguous left-recursive grammars", and Joshua Barretto’s chumsky for Rust offers left recursion and memoization as opt-in features. roc-parser supports neither. Write the repetition explicitly instead: a term followed by many of "-" term.

6.1.8. Packrat parsing and performance§

Backtracking can re-run the same parser at the same position many times, and in the worst case the time grows exponentially with the input. Ford’s packrat parsing (2002) fixes this by memoizing every rule’s result at every position, which guarantees linear time at the cost of memory proportional to the input times the number of rules.

roc-parser does not memoize. For the formats it targets, careful ordering of alternatives keeps backtracking shallow, and memoization would cost memory on every input to protect against grammars that are rare in practice. Where speed matters most, the package goes further: Add a format module explains that the CSV, XML, HTTP and Markdown modules moved their document-level parsing from combinators to byte scanners with an explicit stack, to stay linear in the input size and avoid deep recursion. Combinators remain the interface for composing them and for writing your own parsers.

6.1.9. Combinators and parser generators§

The other tradition is the parser generator. Stephen C. Johnson’s yacc (1975) reads a grammar file and generates an LALR(1) parser; Terence Parr’s ANTLR (described by Parr and Russell Quong in 1995) generates LL(k) parsers, with its later versions using more powerful strategies. A generator analyses the whole grammar before any input is read, so it can report ambiguities and conflicts at build time, it accepts left-recursive rules, and the parsers it generates are fast.

Combinators trade that analysis for being ordinary code. The grammar is written in the host language, can be tested piece by piece with expect, uses the host’s types, and needs no build step. You can add a combinator of your own whenever the grammar needs one, such as a parser whose next step depends on a value already read. Choosing how to read your input covers when that trade is worth making.

6.1.10. Testing against the specification§

The newest layer in this lineage is testing. Koen Claessen and John Hughes’s QuickCheck (2000) introduced property-based testing: state a property that should hold for every input, and let the tool generate inputs to try to break it. roc-parser’s quality process follows that approach. Fuzz targets check properties such as "parsing never crashes", and review scripts compare every format with a mature independent implementation, its oracle, on generated and conformance-suite inputs. Property tests and conformance reviews describes the process.

6.1.11. How roc-parser compares§

IdeaSourceroc-parser
Parser as a function returning the rest of the inputBurge 1975, Wadler 1985, Hutton 1992Yes, with one result instead of a list of successes
Applicative sequencingSwierstra and Duponcheel 1996, McBride and Paterson 2008, elm/parserconst, keep and skip
Monadic sequencingHutton and Meijer 1996 and 1998and_then, and custom for anything else
ChoiceParsec 2001 (committed, with try); Ford 2004 (ordered)Ordered choice that always backtracks; no try
Repetition of empty-matching parsersParsec raises an errorStops at the first element that makes no progress
Error reportingParsec, megaparsec (longest match), elm/parser (context)Furthest failure with a byte offset; line and column in the formats
Input typeattoparsec, nom (bytes)UTF-8 bytes, as the Roc language reference recommends
Memoization, left recursionFord 2002; Frost, Hafiz and Callaghan 2008; chumskyNot supported; write repetition explicitly
QualityClaessen and Hughes 2000Fuzzing, property tests and oracle comparisons

6.1.12. Further reading§

Burge, W. H. (1975). Recursive Programming Techniques. Addison-Wesley. Open Library record

The first published set of parsing combinators, in a book about programming with recursion and higher-order functions. Out of print.

Wadler, P. (1985). How to replace failure by a list of successes. Functional Programming Languages and Computer Architecture, LNCS 201, 113–128. doi:10.1007/3-540-15975-4_33

Represents failure and backtracking with lazy lists of results; the technique behind the first functional parser libraries.

Hutton, G. (1992). Higher-order functions for parsing. Journal of Functional Programming, 2(3), 323–343. doi:10.1017/S0956796800000411

The classic introduction to combinator parsing, building sequencing, choice and repetition from a handful of primitives.

Hutton, G., and Meijer, E. (1996). Monadic parser combinators. Technical report NOTTCS-TR-96-4, University of Nottingham. PDF on Graham Hutton’s site

A long tutorial that recasts combinator parsing in monadic style; its introduction surveys the earlier work.

Hutton, G., and Meijer, E. (1998). Monadic parsing in Haskell. Journal of Functional Programming, 8(4), 437–444. doi:10.1017/S0956796898003050

The short, widely taught version of the same ideas.

Swierstra, S. D., and Duponcheel, L. (1996). Deterministic, error-correcting combinator parsers. Advanced Functional Programming, LNCS 1129, 184–207. doi:10.1007/3-540-61628-4_7

Non-monadic combinators whose fixed structure allows analysis and error correction; the early example of applicative parsing.

Leijen, D., and Meijer, E. (2001). Parsec: Direct style monadic parser combinators for the real world. Technical report UU-CS-2001-27, Utrecht University. Microsoft Research publication page

Committed choice, try, and error messages that name the position and the expected input. The library is on Hackage as parsec.

McBride, C., and Paterson, R. (2008). Applicative programming with effects. Journal of Functional Programming, 18(1), 1–13. doi:10.1017/S0956796807006326

Defines applicative functors and names parsing as a case that needs less than a monad; the theory behind keep and skip.

Ford, B. (2002). Packrat parsing: simple, powerful, lazy, linear time. Proceedings of the Seventh ACM SIGPLAN International Conference on Functional Programming (ICFP), 36–47. doi:10.1145/581478.581483

Memoizes a backtracking parser to guarantee linear time.

Ford, B. (2004). Parsing expression grammars: a recognition-based syntactic foundation. Proceedings of the 31st ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages (POPL), 111–122. doi:10.1145/964001.964011

Prioritized choice and greedy repetition as a grammar formalism; the closest formal model of roc-parser’s one_of and many.

Frost, R. A., Hafiz, R., and Callaghan, P. (2008). Parser combinators for ambiguous left-recursive grammars. Practical Aspects of Declarative Languages (PADL), LNCS 4902, 167–181. doi:10.1007/978-3-540-77442-6_12

One way to support left recursion in combinators, the feature roc-parser leaves out.

Claessen, K., and Hughes, J. (2000). QuickCheck: a lightweight tool for random testing of Haskell programs. Proceedings of the Fifth ACM SIGPLAN International Conference on Functional Programming (ICFP), 268–279. doi:10.1145/351240.351266

Property-based testing, the basis of this package’s fuzz and property tests.

Johnson, S. C. (1975). Yacc: Yet Another Compiler-Compiler. Bell Laboratories. Online copy

The parser generator that set the pattern for grammar files compiled to LALR(1) tables.

Parr, T. J., and Quong, R. W. (1995). ANTLR: A predicated-LL(k) parser generator. Software: Practice and Experience, 25(7), 789–810. doi:10.1002/spe.4380250705

The first journal description of ANTLR, a widely used LL parser generator.

Libraries

  • elm/parser by Evan Czaplicki: parser pipelines (|= and |.), no backtracking by default, and context in error messages. Roc is, in the words of its FAQ, "a direct descendant of Elm".

  • megaparsec: Parsec’s successor, with typed errors and longest-match error merging.

  • attoparsec: fast parsing of bytes and text with arbitrary backtracking and incremental input.

  • nom, winnow (a fork of nom) and chumsky: Rust combinator libraries. nom is byte-oriented and zero-copy, winnow is a fork of nom that keeps the fundamentals in one crate, and chumsky focuses on error recovery.

7. Release notes§

7.1. Published releases§

Use the published release records for package URLs, compiler requirements and upgrade instructions. Choose a final release for normal use; preview releases may change their APIs.

Read the 4.0.0 upgrade notes for JSON byte limits, writer error handling and Roc syntax span changes.

7.1.1. Release candidates§

A release marked prerelease is a preview. Read that release’s notes and keep its package and examples together when testing it.