# oneyaml Rust docs, all pages (oneyaml 1.2.0) Every page of https://recursiveai.org/oneyaml/rust/docs/ and https://recursiveai.org/oneyaml/rust/cookbook/ as markdown, getting started first. Index: https://recursiveai.org/oneyaml/rust/llms.txt # Getting started with oneyaml for Rust Add the oneyaml crate, read YAML into Rust types with serde, write it back, edit a file in place without losing its comments, and apply JSON Patch and merge patches. oneyaml is serde and lossless, comment-preserving editing for YAML 1.2, in one crate. Both run on one parser, so a typed read and an edit never disagree about what a file means. Every sample on this page was compiled and run against oneyaml 1.2.0 from crates.io; the output under each one is what it printed. ## Install oneyaml needs Rust 1.85 or newer (edition 2024). ```sh cargo new yaml-start cd yaml-start cargo add oneyaml@1.2.0 serde -F serde/derive ``` The only runtime dependency by default is `serde`. The crate has no `unsafe` code (`#![forbid(unsafe_code)]`), and every fallible call returns a `Result` or an `Option` instead of panicking. The license is MIT or Apache-2.0, at your choice. The samples are complete `main.rs` files. A few read a file from the current directory; the YAML they read is shown with them. ## Read YAML into a struct Derive `Deserialize` and call `oneyaml::from_str`. Fields bind by name, the way they do with `serde_json`. ```rust use serde::Deserialize; #[derive(Debug, Deserialize)] struct Config { name: String, replicas: u32, ports: Vec, debug: bool, } fn main() -> Result<(), oneyaml::Error> { let src = "\ name: api replicas: 2 ports: [80, 443] debug: false "; let cfg: Config = oneyaml::from_str(src)?; println!( "{} runs {} replicas on {:?} (debug: {})", cfg.name, cfg.replicas, cfg.ports, cfg.debug ); Ok(()) } ``` ```text api runs 2 replicas on [80, 443] (debug: false) ``` YAML 1.2 is the default: `yes` and `no` are strings, not booleans, and `0755` is the decimal 755. A field of the wrong type is an error with a code, a line and a column, and the path to the value (see [errors with line and column](https://recursiveai.org/oneyaml/rust/cookbook/errors-with-line-and-column/)). ## Write YAML `oneyaml::to_string` writes any `Serialize` value as block-style YAML. ```rust use serde::Serialize; use std::collections::BTreeMap; #[derive(Serialize)] struct Config { name: String, replicas: u32, ports: Vec, labels: BTreeMap, } fn main() -> Result<(), oneyaml::Error> { let cfg = Config { name: "api".into(), replicas: 3, ports: vec![80, 443], labels: BTreeMap::from([("tier".into(), "web".into())]), }; print!("{}", oneyaml::to_string(&cfg)?); Ok(()) } ``` ```text name: api replicas: 3 ports: - 80 - 443 labels: tier: web ``` `to_writer` writes to any `io::Write`, and `to_string_with` takes `Settings` for the indent and other output choices. ## Edit a file and keep its comments To change a file that people also edit by hand, parse it into a `Document` instead. An untouched document writes back byte for byte; an edit changes only what it touches, and every comment stays with the entry it belongs to. ```rust use oneyaml::Document; fn main() -> Result<(), Box> { let src = "\ # Deploy settings name: api replicas: 2 # raised by the release job ports: [80] "; let mut doc = Document::parse(src)?; // Read a value. let replicas = doc.get_path("replicas").and_then(|n| n.as_u64()); println!("replicas = {replicas:?}"); // Change a value, append to a list, add a key. doc.set_path("replicas", 3)?; doc.get_mut("ports")?.push(443)?; doc.root_mut()?.insert("region", "us-east-1")?; print!("{doc}"); Ok(()) } ``` ```text replicas = Some(2) # Deploy settings name: api replicas: 3 # raised by the release job ports: [80, 443] region: us-east-1 ``` Paths are strings such as `"server.port"` or `"spec.containers[0].image"`. `set_path` keeps the old value’s style: a quoted string stays quoted, a block scalar stays a block scalar. New content is laid out like its neighbors, so the flow list `[80]` stays a flow list. The rules for which entry a comment belongs to are in [comment attachment](https://recursiveai.org/oneyaml/rust/docs/comment-attachment/). ## Apply a JSON Patch or a merge patch A `Document` applies JSON Patch (RFC 6902) and merge patches (RFC 7396). Both are all or nothing: if any operation fails, the document is exactly as it was. Like the setters, a patch changes only the text of the values it names. ```rust use oneyaml::{Document, MergePatch, Patch}; fn main() -> Result<(), Box> { let src = "\ # Web service image: web:1.0 # pinned env: DEBUG: \"1\" LOG: info ports: [80] "; // JSON Patch (RFC 6902): all or nothing. let mut doc = Document::parse(src)?; let patch = Patch::from_json( r#"[{"op": "replace", "path": "/image", "value": "web:1.1"}, {"op": "add", "path": "/ports/-", "value": 443}]"#, )?; doc.apply_patch(&patch)?; print!("{doc}"); println!("---"); // Merge patch (RFC 7396): null removes a key. let mut doc = Document::parse(src)?; let merge = MergePatch::from_json(r#"{"env": {"DEBUG": null, "LOG": "warn"}}"#)?; doc.apply_merge_patch(&merge)?; print!("{doc}"); Ok(()) } ``` ```text # Web service image: web:1.1 # pinned env: DEBUG: "1" LOG: info ports: [80, 443] --- # Web service image: web:1.0 # pinned env: LOG: warn ports: [80] ``` Patches work on the YAML data model: `test` compares YAML values, so `1` is not `1.0`, and writes below an alias are refused. A `move` carries the value without its comments; to move an entry with them, use `NodeMut::move_entry`. Every difference from JSON is listed in [patches and pointers](https://recursiveai.org/oneyaml/rust/docs/guide/#patches-and-pointers). ## Where next Recipes, each a complete program: - [Parse YAML into Rust structs](https://recursiveai.org/oneyaml/rust/cookbook/parse-yaml-into-structs/) and [read YAML without a schema](https://recursiveai.org/oneyaml/rust/cookbook/read-yaml-without-a-schema/) - [Edit YAML and keep its comments](https://recursiveai.org/oneyaml/rust/cookbook/edit-yaml-keep-comments/) and [merge or patch YAML](https://recursiveai.org/oneyaml/rust/cookbook/patch-yaml/) - [Convert YAML to JSON and back](https://recursiveai.org/oneyaml/rust/cookbook/yaml-to-json/) and [read Kubernetes manifests and multi-document files](https://recursiveai.org/oneyaml/rust/cookbook/kubernetes-multi-document/) - [Report errors with line and column](https://recursiveai.org/oneyaml/rust/cookbook/errors-with-line-and-column/), [anchors, aliases and merge keys](https://recursiveai.org/oneyaml/rust/cookbook/anchors-aliases-merge-keys/) - [Read untrusted YAML safely](https://recursiveai.org/oneyaml/rust/cookbook/untrusted-input/) and [read YAML 1.1 files (`yes`, `no`, `0755`)](https://recursiveai.org/oneyaml/rust/cookbook/yaml-1-1-booleans/) The docs: - The [crate guide](https://recursiveai.org/oneyaml/rust/docs/guide/): diagnostics, patches and pointers, untrusted input, merge keys, feature flags. The API reference with an example for every task is on [docs.rs](https://docs.rs/oneyaml/1.2.0/oneyaml/). - Moving from `serde_yaml`, `serde_yaml_ng` or `serde_norway`: [the migration guide](https://recursiveai.org/oneyaml/rust/docs/migrating-from-serde-yaml/). - Choosing a crate: [how oneyaml compares](https://recursiveai.org/oneyaml/rust/docs/comparison/), [performance](https://recursiveai.org/oneyaml/rust/docs/performance/) and [yaml-test-suite conformance](https://recursiveai.org/oneyaml/rust/docs/conformance/). - What changed: the [changelog](https://recursiveai.org/oneyaml/rust/docs/changelog/). Choosing a Rust YAML crate after serde_yaml: [Rust YAML parser](https://recursiveai.org/oneyaml/articles/rust-yaml-parser/). # oneyaml **serde and lossless, comment-preserving editing for YAML 1.2, in one crate.** Both run on one parser and one interpretation of the file, so a typed read and an edit never disagree about what it means. Correct by default, no `unsafe`, no panics on any input. [![crates.io](https://img.shields.io/crates/v/oneyaml.svg)](https://crates.io/crates/oneyaml) [![docs.rs](https://img.shields.io/docsrs/oneyaml)](https://docs.rs/oneyaml) [![MSRV 1.85](https://img.shields.io/badge/MSRV-1.85-blue.svg)](#minimum-supported-rust-version) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license) How many times fewer CPU instructions oneyaml executes than each competitor (the fastest feature-complete serde library for serde reads and writes, yaml-edit for `Document` parsing and a three-edit script), on five workloads shaped like real configuration files (higher is better; hatched bars are workloads where oneyaml is slower): Chart of CPU instruction counts, not wall-clock time: how many times fewer instructions oneyaml executes than each competitor (competitor ÷ oneyaml; higher is better; 1.0× is equal), against the fastest feature-complete YAML serde library on each workload for serde read and serde write, and yaml-edit for Document parse and Document edit script (insert + set + remove); one bar per workload for 5 workloads (gha-workflow, compose-merge, helm-values, openapi-100k and k8s-multi). serde read vs serde_yaml_ng and serde_norway: 1.0× to 1.7×; faster on all 5. serde write vs serde-saphyr: 1.3× to 1.5×; faster on all 5. Document parse vs yaml-edit: 1.1× to 1.5×; faster on all 5. Document edit script (insert + set + remove) vs yaml-edit: 0.60× to 64×; slower on 1 of 5, faster on 4 of 5 (slower: compose-merge 0.60×). [Full results, with wall-clock times and the method →](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#results-at-a-glance) ## Why oneyaml - **One parser, one meaning.** serde and the lossless `Document` read the same events through the same resolver, so a typed read and an edit never disagree about what a file means; a test checks that they agree on every corpus file ([`consistency.rs`](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/bench/tests/consistency.rs)). - **Edits that keep your file.** `Document::parse(src)?.to_string() == src`, byte for byte, on every valid file of the yaml-test-suite ([round trip](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md#results-classification-and-round-trip)). An edit changes only what you touch: every comment stays with the entry it belongs to, also when you move the entry, by [numbered rules](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/comment-attachment.md), and new content is laid out like its neighbours. JSON Patch and merge patches change values the same way, all or nothing (a patch `move` carries the value without its comments) ([patches and pointers](#patches-and-pointers)). - **Correct YAML 1.2.2 by default.** The Core schema reads `yes` as a string and `0777` as a decimal integer, as the spec says; the Failsafe and JSON schemas are built in. Every YAML 1.1 behaviour, [merge keys](#merge-keys-) included, and every leniency is a named setting, off by default ([known deviations](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md#known-deviations)). yaml-test-suite `data-2022-01-17`, 402 cases: **402/402** under the standard criterion (valid 308/308, error 94/94), **402/402** strict (exact events, error prefixes and codes), and 308/308 valid inputs round-trip byte for byte ([evidence, other Rust parsers' scores and how to reproduce](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md)). - **Safe on any input.** Every published crate is `#![forbid(unsafe_code)]`. Every input gives a value or an error, every fallible API returns `Result` or `Option`, and parsing is iterative, so deep nesting can't overflow the stack. Every read enforces resource limits; for input from the network, use [`Limits::untrusted()`](#untrusted-input). Parsing, serde, editing, comments and diagnostics are fuzzed ([crates/fuzz](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/fuzz/README.md)) and tested against `serde_yaml_ng`, saphyr, yaml-rust2, libyaml and yaml-edit. - **Errors you can act on.** Every error has a stable `Y####` code, a span and a path, and common mistakes come with a suggested fix ([diagnostics](#diagnostics)). - **Fast, with the losses shown.** oneyaml executes fewer instructions than each competitor on every workload in the chart above but one, and the cost of a measured edit depends on what it touches, not on the file's size (a patch also reads the keys of each mapping it looks into, once per patch). It isn't faster everywhere: yaml-edit edits the smallest file in fewer instructions and less time, a typed read of the OpenAPI workload is slower in wall-clock time, and some serde reads outside the chart are slower or level ([every loss](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#results-at-a-glance)). The only runtime dependency by default is `serde` ([feature flags](#feature-flags)). How oneyaml compares with `serde_yaml_ng`, `serde_norway`, serde-saphyr and yaml-edit, including where they win: [docs/comparison.md](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/comparison.md). ## Install ```sh cargo add oneyaml serde -F serde/derive ``` ## Quick start Read and write typed data with serde, then edit a document in place: the edit changes only what you touched, and the comment stays. ```rust use oneyaml::Document; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] struct Config { name: String, port: u16 } fn main() -> Result<(), Box> { // serde: read and write typed data. let cfg: Config = oneyaml::from_str("name: web\nport: 8080\n")?; assert_eq!(oneyaml::to_string(&cfg)?, "name: web\nport: 8080\n"); // Read values from a document. let mut doc = Document::parse("server:\n port: 8080 # public\ntags: [web]\n")?; assert_eq!(doc.get_path("server.port").and_then(|n| n.as_u64()), Some(8080)); // Edit it: everything else, comments included, stays byte for byte. doc.set_path("server.port", 9090)?; doc.get_mut("tags")?.push("api")?; assert_eq!(doc.to_string(), "server:\n port: 9090 # public\ntags: [web, api]\n"); Ok(()) } ``` ## Diagnostics An error says what went wrong, where, and how to fix it. `Display` is one line for logs; [`Error::render`](https://docs.rs/oneyaml/latest/oneyaml/struct.Error.html#method.render) prints a rustc-style snippet of your source with a stable `Y####` code, labels, help, the path to the value, and a suggested fix you can apply: ```rust use serde::Deserialize; #[derive(Deserialize)] struct Config { debug: bool } fn main() -> Result<(), Box> { // `yes` is a boolean in YAML 1.1 but a string in YAML 1.2. let src = "debug: yes\n"; let err = oneyaml::from_str::(src).err().ok_or("expected an error")?; assert_eq!(err.render(src), r#"error[Y3000]: invalid type: string "yes", expected a boolean; YAML 1.2 reads `yes` as a string (its booleans are `true` and `false`) --> 1:8 | 1 | debug: yes | ^^^ | = help: write `true` instead of `yes`, or read the YAML 1.1 booleans (y/n, yes/no, on/off) as booleans with Settings::yaml11_booleans(true) or the Compat::yaml_1_1() preset = path: debug = suggestion (may be incorrect): write the YAML 1.2 boolean `true`: replace `yes` with `true` "#); let fix = err.diagnostic().suggestions.first().ok_or("no fix")?; assert_eq!(fix.apply(src).as_deref(), Some("debug: true\n")); // A forgotten `]`: the error points at the `[` that is still open // and offers to close it after `443`. let src = "ports: [80, 443\nimage: nginx\n"; let err = oneyaml::Document::parse(src).err().ok_or("expected an error")?; let fix = err.diagnostic().suggestions.first().ok_or("no fix")?; assert_eq!(fix.apply(src).as_deref(), Some("ports: [80, 443]\nimage: nginx\n")); Ok(()) } ``` A fix is marked machine-applicable only when applying it can't change what the rest of the file means; any other fix, like the two above, is labelled for review. Codes are never reused, and limit errors name the setting to raise. [`Document::parse_recovering`](https://docs.rs/oneyaml/latest/oneyaml/struct.Document.html#method.parse_recovering) keeps going past the first error, so you see a file's errors together, and the `miette` feature plugs errors into miette's reports. More on [diagnostics on `Error`](https://docs.rs/oneyaml/latest/oneyaml/struct.Error.html#diagnostics). ## Patches and pointers A `Document` applies JSON Patch (RFC 6902) operations on YAML, and merge patches (RFC 7396), all or nothing: if any operation fails, the document is exactly as it was. A patch changes only the text of the values it names, so the comments and layout around them stay: ```rust use oneyaml::{Document, MergePatch, Patch, PatchErrorKind}; fn main() -> Result<(), Box> { let mut doc = Document::parse("# app\nimage: web:1.0 # pinned\nports: [80]\n")?; let patch = Patch::from_json( r#"[{"op": "test", "path": "/image", "value": "web:1.0"}, {"op": "replace", "path": "/image", "value": "web:1.1"}, {"op": "add", "path": "/ports/-", "value": 443}]"#, )?; doc.apply_patch(&patch)?; assert_eq!(doc.to_string(), "# app\nimage: web:1.1 # pinned\nports: [80, 443]\n"); // The `test` fails now, so nothing is applied. let err = doc.apply_patch(&patch).err().ok_or("expected an error")?; assert_eq!(err.kind(), PatchErrorKind::Test); assert_eq!(doc.get_pointer("/ports/1")?.as_u64(), Some(443)); // A merge patch: `null` removes a key. let mut doc = Document::parse("image: web:1.0 # pinned\nenv:\n DEBUG: \"1\"\n LOG: info\n")?; doc.apply_merge_patch(&MergePatch::from_json(r#"{"image": "web:1.1", "env": {"DEBUG": null}}"#)?)?; assert_eq!(doc.to_string(), "image: web:1.1 # pinned\nenv:\n LOG: info\n"); Ok(()) } ``` [`Document::get_pointer`](https://docs.rs/oneyaml/latest/oneyaml/struct.Document.html#method.get_pointer) reads a node by JSON Pointer, and `DocumentMut` and `Stream` apply patches too. To change one value without a patch, the setters ([`Document::set_path`](https://docs.rs/oneyaml/latest/oneyaml/struct.Document.html#method.set_path), [`NodeMut::set`](https://docs.rs/oneyaml/latest/oneyaml/struct.NodeMut.html#method.set)) write exactly the value you give and keep the old value's style: a block scalar stays a block scalar, quotes stay, and so does a tag that still fits. Patches work on the YAML data model, so they differ from JSON Patch in a few places: `test` compares YAML values (`1` is not `1.0`), a pointer token matches a key by its text, and writes below an alias or to a key merged in by `<<` are refused. `copy` and `move` write the value, not its text: the comments and anchors inside a moved value are dropped. To move an entry with its comments, use [`NodeMut::move_entry`](https://docs.rs/oneyaml/latest/oneyaml/struct.NodeMut.html#method.move_entry) or [`Removed`](https://docs.rs/oneyaml/latest/oneyaml/struct.Removed.html). [`Patch`](https://docs.rs/oneyaml/latest/oneyaml/struct.Patch.html) lists every difference and what each operation keeps, and says what a successful patch guarantees. `Patch::from_json` also reads YAML text, with the default limits; for untrusted text, read a `Value` with `from_str_with` and `Limits::untrusted()` and build the patch with `Patch::from_value`. ## When not to use oneyaml - **You need the fastest serde reads in wall-clock time.** oneyaml's serde reads use fewer instructions on most workloads, but in wall-clock time `serde_yaml_ng` or `serde_norway` is faster on some of them: typed reads of the OpenAPI workload, most reads into structs that use a few fields, and some reads into `Value` ([serde read wall-clock](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#wall-clock-informational-only-1)). - **You make a few edits to many small files and edit speed matters.** Below a few kilobytes, yaml-edit's edits cost fewer instructions and less wall-clock time (compose-merge in the chart above; see the [benchmark tables](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#results-at-a-glance)). - **You need something oneyaml doesn't do:** `no_std`, async I/O, schema validation, templating, building objects from tags, or editing a file too large to hold in memory (`Document` keeps the whole tree; serde reads don't). - **You want `serde_yaml`'s exact behaviour with no review.** `Compat::serde_yaml()` gets close, but some differences are deliberate; the [migration guide](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/migrating-from-serde-yaml.md#divergences) lists every one. - **You need a long production track record.** `serde_yaml`'s lineage has years of it; oneyaml is new. ## Untrusted input Every entry point enforces resource limits (depth, alias expansion, document size, scalar length, document count). The default profile suits configuration files; for input from the network, use `Limits::untrusted()`: ```rust use oneyaml::{Limits, Settings, Value}; fn main() -> Result<(), Box> { let settings = Settings::new().limits(Limits::untrusted()); let small = "a: &a [x, x]\nb: &b [*a, *a]\nc: &c [*b, *b]\n"; let v: Value = oneyaml::from_str_with(small, &settings)?; // small: fine assert_eq!(v["c"][0][1][0].as_str(), Some("x")); // "Billion laughs": ten levels of ten aliases each. let mut laughs = String::from("l0: &l0 [lol]\n"); for i in 1..10 { let refs = vec![format!("*l{}", i - 1); 10].join(", "); laughs.push_str(&format!("l{i}: &l{i} [{refs}]\n")); } let refused = oneyaml::from_str_with::(&laughs, &settings); assert_eq!(refused.err().map(|e| e.code()), Some(oneyaml::code::ALIAS_BUDGET_EXCEEDED)); Ok(()) } ``` There are three profiles: `Limits::default()`, `Limits::untrusted()` and `Limits::unlimited()`, the only one without alias budgets (for input you trust completely). Exceeding a limit is an error whose help names the setting to raise. The alias budgets count the nodes and bytes that alias expansion produces beyond the input, and an alias whose whole expansion would exceed a budget is refused before it is replayed, so "billion laughs" fails fast. `Document` never expands aliases, and compatibility presets never change limits. The values of each profile and how the budgets are counted are on [`Limits`](https://docs.rs/oneyaml/latest/oneyaml/struct.Limits.html). ## Merge keys (`<<`) YAML 1.2 has **no merge keys**: by default `<<` is an ordinary string key, as the spec says. docker-compose files and many CI configurations rely on the YAML 1.1 merge-key extension; turn it on with one setting: ```rust use oneyaml::{Settings, Value}; fn main() -> Result<(), Box> { let src = "base: &b {image: nginx, replicas: 2}\nweb:\n <<: *b\n replicas: 3\n"; let v: Value = oneyaml::from_str_with(src, &Settings::new().merge_keys(true))?; assert_eq!(v["web"]["image"].as_str(), Some("nginx")); assert_eq!(v["web"]["replicas"].as_u64(), Some(3)); Ok(()) } ``` `Compat::yaml_1_1()` turns on every YAML 1.1 behaviour, merge keys included; `Compat::serde_yaml()` leaves them off, as `serde_yaml`'s deserializer does. Merges through aliases count toward the alias budget. `Document` keeps `<<` entries as written and edits them like any other entry; [`examples/compose_merge.rs`](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/oneyaml/examples/compose_merge.rs) edits a docker-compose file that uses them. Details: [`Settings::merge_keys`](https://docs.rs/oneyaml/latest/oneyaml/struct.Settings.html#method.merge_keys). ## Going further The API reference on docs.rs has a worked, tested example for each task, and the guides in the repository cover the rest: - **Read and edit a document.** [`Document`](https://docs.rs/oneyaml/latest/oneyaml/struct.Document.html) walks the core path (parse, look up, edit, write), says how new content and set values are written, and lists what an edit refuses; [`NodeRef`](https://docs.rs/oneyaml/latest/oneyaml/struct.NodeRef.html#reading) covers typed reads, spans and `deserialize`; [`NodeMut`](https://docs.rs/oneyaml/latest/oneyaml/struct.NodeMut.html#values-keys-and-refusals) covers edits in depth, and [`Removed`](https://docs.rs/oneyaml/latest/oneyaml/struct.Removed.html) covers moving an entry. Paths are strings (`"spec.containers[0].image"`) or `path!` values; the syntax, and how to quote a key that holds a `.`, are on [`IntoPath`](https://docs.rs/oneyaml/latest/oneyaml/trait.IntoPath.html). - **Apply patches.** [`Document::apply_patch`](https://docs.rs/oneyaml/latest/oneyaml/struct.Document.html#method.apply_patch) and [`apply_merge_patch`](https://docs.rs/oneyaml/latest/oneyaml/struct.Document.html#method.apply_merge_patch) cover what each operation writes, how a pointer token matches a key, and the limits a patch is held to; [`PatchError`](https://docs.rs/oneyaml/latest/oneyaml/struct.PatchError.html) names the operation that failed. - **Keep and edit comments.** Each comment has an owner, set by the [comment attachment rulebook](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/comment-attachment.md); edits move comments with their owner, and `explain_comment` tells you which rule decided. See [comments on `Document`](https://docs.rs/oneyaml/latest/oneyaml/struct.Document.html#comments). - **Multi-document files, anchors and aliases.** A file with several documents is a [`Stream`](https://docs.rs/oneyaml/latest/oneyaml/struct.Stream.html), read and edited one document at a time (or iterated with serde). Editing an anchored node is seen through every alias; see [anchors and aliases](https://docs.rs/oneyaml/latest/oneyaml/struct.Document.html#anchors-and-aliases). - **Diagnostics and recovering parses.** Every error has a `Y####` code ([`code`](https://docs.rs/oneyaml/latest/oneyaml/code/index.html)), a span, a path and, for common mistakes, a fix you can apply; see [diagnostics on `Error`](https://docs.rs/oneyaml/latest/oneyaml/struct.Error.html#diagnostics) and [`Render`](https://docs.rs/oneyaml/latest/oneyaml/trait.Render.html). [`Document::parse_recovering`](https://docs.rs/oneyaml/latest/oneyaml/struct.Document.html#method.parse_recovering) never fails, and you can still read and edit everything outside the broken regions. - **Serialize and configure.** [`to_string`](https://docs.rs/oneyaml/latest/oneyaml/fn.to_string.html) and `to_writer` write any `Serialize`, quoting every string that some reader would read as something else. Every reading and parsing entry point has a `*_with` twin (`Patch::from_json` and `MergePatch::from_json` excepted: they read with the defaults) that takes an explicit [`Settings`](https://docs.rs/oneyaml/latest/oneyaml/struct.Settings.html) (there are no globals): the schema, YAML 1.1 behaviours, duplicate-key handling, output layout ([`Emit`](https://docs.rs/oneyaml/latest/oneyaml/struct.Emit.html)) and resource limits ([`Limits`](https://docs.rs/oneyaml/latest/oneyaml/struct.Limits.html)). - **Migrate from `serde_yaml`.** The entry points keep `serde_yaml`'s names (`from_str`, `from_slice`, `from_reader`, `to_string`, `to_writer`, `from_value`, `to_value`, `Value`, `Mapping`, `Deserializer`, `Serializer`), so migrating starts with a `use` swap, and `Compat::serde_yaml()` reads and writes as `serde_yaml` does where YAML 1.2 allows. The [migration guide](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/migrating-from-serde-yaml.md) walks through it and lists every remaining difference with its reason. - **Performance in detail.** [Results at a glance](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#results-at-a-glance) has the numbers behind the chart as tables (absolute instruction counts, both ratios, wall-clock time, every workload where oneyaml is slower and the machine they were measured on); the rest of [docs/performance.md](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md) covers every workload, memory, scaling and how to reproduce it all. - **Real-world programs.** [`examples/`](https://github.com/recursive-ai-oss/oneyaml-rust/tree/v1.2.0/crates/oneyaml/examples) starts with [`edit_config.rs`](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/oneyaml/examples/edit_config.rs) (the quick start as a program), then edits a [Kubernetes manifest](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/oneyaml/examples/k8s_manifest.rs), a [GitHub Actions workflow](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/oneyaml/examples/gha_workflow.rs) and a [docker-compose file with merge keys](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/oneyaml/examples/compose_merge.rs), checking every comment's owner before and after. Run one with `cargo run -p oneyaml --example k8s_manifest`. ## Feature flags All are off by default; without `miette` and `serde_json`, the only third-party runtime dependency is `serde`. - **`miette`**: `Error` implements `miette::Diagnostic` (code, severity, labels, help with the suggested fixes, and a documentation URL per code); `DiagnosticReport` does the same for a `Diagnostic`. Adds `miette` 7 without its default features (it brings `cfg-if` and `unicode-width`). - **`serde_json`**: `TryFrom` conversions between `Value` and `serde_json::Value` in both directions, and `Value::to_json_with`. What has no JSON form (tags, non-string keys, `.nan`/`.inf`, integers beyond 64 bits, nesting past 128 levels) is an error with its path, never a silent coercion; the full table is on `Value::to_json_with`. Adds `serde_json` with only its `std` feature. - **`__internal-checks`** (unstable, not covered by semver): test-only consistency checks used by this crate's own test suites and fuzzers. Never enable it in a library or application. ## Minimum supported Rust version Rust **1.85** (edition 2024), for `oneyaml`, `oneyaml-core` and `oneyaml-syntax`. The MSRV is rolling: it may move to any stable release at least 12 months old, only in a minor release, and every move is announced in the [changelog](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/CHANGELOG.md). ## License Licensed under either of - Apache License, Version 2.0 ([LICENSE-APACHE](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/LICENSE-APACHE) or ) - MIT license ([LICENSE-MIT](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/LICENSE-MIT) or ) at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in oneyaml by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. See [CONTRIBUTING.md](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/CONTRIBUTING.md) and [SECURITY.md](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/SECURITY.md). # Comment attachment rulebook > **Status: normative.** Implemented by `oneyaml-syntax` > (`tree/attach.rs`: `SyntaxTree::comment_attachment`, > `SyntaxTree::comment_attachments`) and explained by > `oneyaml::Document::explain_comment`. > > **Stability.** Rule numbers are stable once published: a number is never > reused or renumbered, new rules are appended. Owner assignments do not > change within a major version except to fix a clear misattribution, > called out in the changelog. Every rule has > golden tests (`crates/oneyaml/tests/comment_golden.rs` and its > snapshot). > > Rules 1–9 are the core rules; rules 10–13 refine them. Section 6 lists > the cases where the rules needed a decision, with the choice made and > why. ## 1. Vocabulary - **Comment**: a `#` comment token of the syntax tree, from `#` to the end of its line (storage is byte-exact; nothing here changes bytes). - **Content token**: a token with bytes that is not white space, a line break, a comment, or a byte order mark (scalar text, indicators `-` `?` `:` `,`, brackets, properties `&a` `!t`, block scalar headers, alias names, directives, `---`, `...`, error bytes). - **Line**: lines end at line break tokens (LF, CRLF, or CR, §5.4) and at the end of a block scalar's text, which ends with its own line break. - **Same-line comment**: a comment with a content token before it on its line. **Own-line comment**: any other comment. - **P** (previous) and **N** (next): the content tokens right before and right after a comment, ignoring every comment, white space, line break, and zero-width token between them. A **run** is the stretch between one P and its N; all comments of a run share P, N, and everything derived from them. - **Column** of a token: the number of characters (Unicode scalar values; a tab counts as one) between the start of its line and the token. A byte order mark counts as no column (the parser resets the column after it, YAML 1.2.2 §5.2). - **Entry**: a mapping key/value pair, named by its **key** node; it starts at its `?` when it has one, else at its key. **Item**: a sequence element, named by its node; a block item starts at its `-`, a flow item at its node. An entry's or item's comments are its key's or node's. - **Root**: the document's first node that has bytes. A document with no such node has **no visible root** (e.g. `---` alone). - **Block collection's column**: the column where its first entry or item starts (its own `&anchor`/`!tag` properties are not part of it). - **Closing collections** of a run: the block collections that contain P but not N (they end with P), innermost first. Their columns never increase from inner to outer in a well-formed tree. - **What N opens** (the *target* of a leading comment): the innermost entry or item that starts at N; else the outermost node that starts at N; else, when N is the `:` of an explicit entry, that entry's value; else the innermost node that holds N. ## 2. Precedence Every comment falls to exactly one rule, decided in this order: 1. A **same-line** comment: **rule 5**, always. 2. An **own-line** comment: find its document context (section 4), then: 1. before the root (or in a document without visible root): **rule 1**, else **rule 2**, else **rule 3**; 2. inside the root, in a flow collection: **rule 9**; 3. inside the root, in block context: **rule 6** if it applies, else **rule 10** if the comment is left of N, else **rule 4 / 11 / 12** by what N opens; 4. after the root: **rule 6** if it applies, else **rule 7** — or, for a comment between documents: **rule 1** whenever a `---` follows, else rule 6, else the next document's header rules (2, 3), else rule 7. 3. Then **rule 13** over each run, in source order: an own-line comment whose owner from step 2 is deeper than the owner of an earlier comment of its run takes the owner of the comment before it. Rule 8 (blank lines) decides no owner: it is a count every comment carries. ## 3. The rules ### Rule 1 — Document header, `---` form An own-line comment **before the document's `---`** is the **document header**, whatever blank lines surround it and whatever its column. This includes comments among the `%YAML`/`%TAG` directives, and every comment between the previous document and this one's `---` — also an indented one after the previous document's last block collection (`items:⏎ - a⏎ # - b⏎---`: `# - b` is the next document's header; rule 6 never reaches past a `---`; see section 6, R1). ### Rule 2 — Document header, blank-line form An own-line comment before the root (after `---`, or at the top of a document written without `---`) with **a blank line somewhere between it and the root** is the **document header**. An own-line comment in a document **without a visible root** is also document header — including the comments after such a document when it ends without `...` and no next document follows (`--- ⏎# c`) — as is a comment of an input that has no document at all (its owner is then the stream: `DocumentHeader` with no document). ### Rule 3 — Document header, symmetric case: first node's leading An own-line comment before the root with **no blank line between it and the root** is a **leading comment of what the root's first token opens**: the root mapping's first entry, the root sequence's first item, else the root node itself (e.g. a root mapping with an `&anchor` line, or a scalar). *Choice:* "the first node" is the innermost entry or item the root opens with, not the root collection. A comment directly above `a: 1` is about the entry `a`; a comment about the whole document is separated by a blank line (rule 2) or placed before `---` (rule 1). The column condition of rule 4 does not apply at the top (there is nothing shallower for the comment to belong to). ### Rule 4 — Leading comments An own-line comment inside the root, in block context, followed (only comments, white space and line breaks between — **blank lines included**, D1 in section 6) by content token N, **at or right of N's column**, is a **leading comment of what N opens**: the entry whose key or `?` N is, the item whose `-` N is, else the node N starts (a value on the line below its key, `k:⏎ # c⏎ v`, leads `v`; a collection starting with an `&anchor` line leads the collection) — unless rule 6 applies first. ### Rule 5 — Trailing comments A same-line comment belongs to the **innermost entry or item containing P** (the content token before it on its line), found by walking up from P: - in a mapping, the entry whose key, `?`, `:`, value, or (flow) `,` P is in — so for `key: # c` with a block value on the next lines, the owner is the **entry**, and properties on the key line (`k: &a # c`) belong to it too; - in a sequence, the item whose `-`, node, or (flow) `,` P is in; - a collection's own bracket or properties are skipped, so `k: [ # c` trails the entry `k` and `a: [1, 2] # c` trails the entry `a`; - P in the root but in no entry (a root scalar, a root flow collection's brackets): **trailing comment of the root**; - P a directive or `---`: **document header**; P a `...`: **document footer**; P a stray `...` between documents, or content of an error region outside every document: the next document's header, else the previous document's footer, else (no document at all) the stream's header; - P error bytes skipped at document level (error recovery): **document footer** (section 5). ### Rule 6 — Collection footer An own-line comment after a block collection's last child is that collection's **footer** when its column is **right of the column of what follows at the parent level**: - inside the root, "what follows" is N: the rule applies when the run has closing collections and the comment's column is greater than N's; - after the root, "what follows" is the root's level: the rule applies when the run has closing collections (the root is block) and the column is greater than the root's column. After an implicitly ended document this holds at stream level only when **no `---` follows** (the last document, or a next document written without `---` in recovered input): a comment before a `---` is always rule 1; - the owner is the **innermost closing collection whose column is at or left of the comment's column**; if every closing collection is right of it, the outermost one. A comment after a document's explicit `...` never belongs to that document's collections. *Choice (rule 4 vs rules 6/7):* comment lines after a block scalar are decided like any other comment lines. After a **middle** entry they lead the next entry (rule 4, or 10 when shallower) — a block scalar is not a collection, so it has no footer. After the **last** entry they are the enclosing collection's footer (rule 6) or the document footer (rule 7), by column. ### Rule 7 — Document footer An own-line comment after the root's last token that rule 6 does not claim (its column is at or left of the root's, or the root is a scalar or flow collection) is the **document footer**, up to the document's `...`. Between documents, when the next document exists it takes the comment (rules 1–3) instead; after the last document (explicit `...` or not), the comment is the last document's footer. ### Rule 8 — Blank lines Every comment carries the number of **blank lines** (lines holding only white space) between the previous non-blank line and its own line; a same-line comment carries 0. Blank lines that belong to a block scalar's text (kept by `|+`) are content, not separation, and are not counted. The count travels with the comment when an edit moves it; the same count for nodes is exposed with the comment API. ### Rule 9 — Flow collections An own-line comment inside a flow collection (the innermost collection holding both P and N is a flow collection) follows the same owner model item by item, with no column conditions (indentation carries no meaning in flow context): - if the next content after it, `,` skipped, is a closing `]` or `}`, the comment is **that collection's footer**; - otherwise it is a **leading comment of the next item or entry** (what that content opens, as in rule 4). Same-line comments in flow context are rule 5 (e.g. `a, # c` trails `a`; `[ # c` trails the collection's entry or item, or the root). ### Rule 10 — Shallow leading comments (refines rule 4) An own-line comment inside the root, in block context, **left of N's column**, that rule 6 does not claim, is still a **leading comment of what N opens**. *Choice:* rule 4 requires the column to be at least the node's indentation but says nothing about a shallower comment above a node that continues the same collection (`a:⏎ b: 1⏎# c⏎ d: 2`). Nothing closes at the comment, so no footer can take it; the next node is the only owner that keeps it in place. Its own rule number makes the case visible in `explain_comment`. ### Rule 11 — Explicit entries: the `:` line An own-line comment directly above the `:` of an explicit (`? `) entry in block context is a **leading comment of that entry's value** (an empty value included). In flow context rule 9 fires and gives the same owner. Comments between `?` and its key on a later line lead the entry (rule 4, the key names it). ### Rule 12 — Inside a node: properties on their own line An own-line comment followed by content that **starts no node** — a property after the node's first property (`&a⏎# c⏎!!map`), a block scalar header on its own line, or error bytes — is a **leading comment of the innermost node holding that content**. A comment above a node's *first* property leads the node (rule 4: N starts it); a comment between a collection's properties and its first entry leads that entry (rule 4). ### Rule 13 — Run order: owners only move outward Within one **run** (the comment lines between one content token P and the next, N), attachment is **monotonic**: every owner's comments are contiguous, in source order, with the owner (edits move an owner's comments as one range, which relies on this). The owners an own-line comment of a run can get are **ranked from deep to outer**: the run's closing block collections, innermost first (rule 6), then everything past them — the next node's leading comment (rules 4, 10, 11, 12), the document footer (rule 7), the next document's header (rules 1, 2), a flow collection's item or footer (rule 9). Decide each comment of the run in source order by rules 1–12; **when its owner ranks deeper than the owner of an earlier comment of the run, it takes the owner of the comment before it** (rule 13), and so does every later comment of the run that would again go deeper. Once a run has reached an outer owner, no later comment of the run returns to a deeper one. Examples (goldens `r13_*`): - `a:⏎ b:⏎ c: 1⏎ # c2⏎ # c4`: `# c2` is the footer of `b:`'s mapping (rule 6); `# c4` would be the footer of the deeper `c: 1` mapping, but that footer would not be contiguous with its mapping: it joins `# c2` (rule 13). - `a:⏎ - 1⏎# lead b⏎ # - 2⏎b: 1`: `# lead b` leads `b` (rule 4); `# - 2` would be the list's footer, but lies between `# lead b` and `b`: it leads `b` too (rule 13). - `a:⏎ - 1⏎# lead b⏎⏎ # - 2` (end of document): `# lead b` is the document footer (rule 7); `# - 2` joins it (rule 13). Rules 2/3 at the top of a document are already in this order (header comments come before the first node's leading comments), and rule 9 gives a run one owner, so rule 13 changes only block-context footers. ## 4. Document context of an own-line comment A document's children are its directives, `---`, the trivia around its root, the root, what error recovery leaves after the root, and `...`. Let D(P) and D(N) be the documents holding P and N, and XP and XN the children of those documents holding them. For a token outside any document (a stray `...`, or content of an error region at stream level), D is the previous document for P and the next one for N — the documents around the stream-level element holding the token. - **Same document** (P and N both in D): - XN a directive or `---`: rule 1; - XP a directive or `---` (nothing of the root before the comment): rules 2/3 with XN as the root, or rule 2 when XN is not a node with bytes (e.g. the `...` of a document with no visible root); - XP = XN, a node: the comment is inside the root: rules 4, 6, 9, 10, 11, 12; - otherwise (after the root: N is `...` or error bytes after the root): rule 6, else rule 7. - **Between documents** (the comment is outside every document): - rule 1 when the next document begins with `---` or a directive; - else rule 6 for D(P) when P is in D(P) after its content (D(P) ended without `...`); - else, when a next document exists: rules 2/3 for it (rule 2 when N is a stray token before it); - else rule 2 for D(P) when P is its `---` or a directive (no content, no `...`); - else rule 7 for D(P); - else (the input has no document) rule 2, owner the stream. Each case is decided from the kinds of P, N, XP, XN and of the next document's first child, so no decision scans a document's children. The passes (`SyntaxTree::comment_attachments`, `SyntaxTree::document_comment_attachments`, and so `Document::comment_ids` and `Document::explain_comments`) never scan the stream's elements per comment either: they table the documents around each stream-level element once (O(n)). The one-comment path (`SyntaxTree::comment_attachment`, `Document::explain_comment`) reads P and N, the documents around a stray `...` or a stream-level error region, and rule 13's deepest owner among the earlier comments of its run from the syntax layer's comment-run index, without walking the run or the stream's elements: its cost is O(depth + log + its line), whatever the edits before. A comment whose owner is a document header or the first node (rules 2/3) also reads the blank lines after it in its run; columns beyond `u32::MAX / 2` and recovered runs mixing header and footer owners are replayed (O(the run)). ## 5. Error recovery Trees built from input with errors follow the same rules: an error region (an `Error` node) is an item wherever it stands, and error bytes are content tokens. Every comment still gets exactly one owner and nothing panics; owners inside a recovered region are best effort and are not covered by the stability promise. In particular: - a same-line comment after error bytes skipped at document level is the document footer; - where a recovered mapping's items cannot be paired from their neighbours (no `?`, `:`, `,` or bracket tells a key from a value), the item is read as a key: pairing is never decided by counting the mapping's items (that would cost more than linear time); - a document can begin or end with trivia (the comments of an unclosed `[` cut off by the next `---`): its comments follow the rules like any other, and may belong to the neighbouring document; - an owner is never the empty placeholder the parser puts right after an error region: the placeholder has no bytes and is the last child of its collection (everything after it on the following lines is skipped at document level), so no rule reaches it — owners are the nodes navigation presents, the error region itself included (checked by the syntax fuzz). ## 6. Choices and their reasons The cases where the rules needed a decision, with the choice made and why. The first table lists the cases where a simpler, literal reading of the core rules would decide differently or leave a comment without an owner. ### Where a literal reading falls short | # | A literal reading says | This rulebook | Why | |---|---|---|---| | D1 | Rule 4: comment lines directly above a node, **no blank line between**, lead it. | Blank lines between the comment and the node do not matter (rule 4, or 10). | Read literally, a comment separated from the next node by a blank line inside a document (`a: 1⏎⏎# about b⏎⏎b: 2`) matches no rule: rules 1–3 are for the top, rule 6 needs a deeper column, rule 7 needs to be after the root. It would have no owner. Attaching it to the next node keeps it with the content it sits above when that content moves, and its blank lines travel with it as rule 8 counts. | | D2 | Rule 4: leading only when the comment's column is **at least the node's indentation**. | A shallower comment that no footer claims still leads the next node, as rule 10. | The same gap as D1: `a:⏎ b: 1⏎# c⏎ d: 2` has no other owner (nothing closes at the comment). Rule 10's own number keeps the case visible in `explain_comment`. | | D3 | Rule 3: the block becomes the **first node's** leading comment. | The innermost entry or item the root opens with (C1). | "First node" could also mean the root collection; a comment directly above `a: 1` is about the entry `a`. | | D4 | Rules 6/7: an own-line comment after a collection's last child, **indented deeper than the next node at the parent level**, is that collection's footer; at root indentation, the document footer. | Rule 13 (run order): within one run, once a comment has gone to an outer owner, a later, deeper comment takes that outer owner instead of its rule-6 footer. So in `a:⏎ - 1⏎# lead b⏎ # - 2⏎b: 1` the commented-out item `# - 2`, deeper than `b`, leads `b` (golden `r13_footer_after_next_leading`); in `a:⏎ - 1⏎# lead b⏎⏎ # - 2` the column-2 `# - 2` is the document footer (golden `r13_footer_after_document_footer`). | Every owner's comments must be contiguous with it in source order, because edits move an owner's comments as one range. **Alternative considered (not implemented):** when a later comment of the run is deeper, pull the earlier, shallower comments inward instead, so commented-out items stay with their list (`# lead b` and `# - 2` would both be the list's footer). That also keeps owners contiguous, but a shallow comment then belongs to a collection it is outdented from. | ### Rejected alternative - **R1** (rule 6 across documents: a deeper commented-out item before `---` stays the previous document's collection footer) was considered and rejected. Rule 1 stands: comments before `---` are always the next document's header. ### Recorded choices | # | Case | Choice | |---|---|---| | C1 | Rule 3's "first node" | The innermost entry/item the root opens with (else the root); see D3. | | C2 | Rule 3 column condition | Not applied at the top of a document. | | C3 | Comment on the `---`/directive line, `...` line | Header / footer (rule 5). | | C4 | Rule 1 vs 6/7 between documents | Rule 1 whenever a `---` follows; otherwise rule 6, then the next document's rules 2/3, then the previous document's footer (rule 7). | | C5 | After `...` with no next document | Last document's footer (rule 7). | | C6 | Rule 4 vs 6/7 after a block scalar | Middle entry: next entry's leading (rule 4/10). Last entry: footer (rule 6) or document footer (rule 7). | | C7 | Shallower than the next node, nothing closes | Leading of the next node (rule 10); see D2. | | C8 | Deeper than every closing collection | Outermost closing collection's footer (rule 6). | | C9 | Explicit `? ` entries | `:` line comment → the value (rule 11); `?`-to-key comment → the entry. | | C10 | Properties/headers on their own line | The node holding them (rule 12). | | C11 | Flow context | No column rules; `,` skipped; before `]`/`}` → footer (rule 9). | | C12 | Value on the line below its key (`k:⏎ # c⏎ v`) | Leading of the value node `v` (rule 4), not of the entry. | | C13 | Blank lines inside `|+` text | Content, not counted (rule 8). | | C14 | Input with no document | Header, owner the stream (rule 2). | | C15 | Document with no visible root (`---` alone) | Its comments, and those after it when it ends without `...` and no document follows, are header (rule 2). | | C16 | Stray `...` (or stream-level error region) between documents | Same line: next document's header, else previous document's footer, else the stream's header (rule 5). Own line before a stray token: the next document's header (rule 2), else the previous document's footer (rule 7). | | C17 | Rules 2/3 after `---` | Applied after `---` too: a blank line before the root makes a header, none makes the first node's leading comment. | | C18 | Rule 7 "at root indentation" | At or left of the root's column; after a scalar or flow root, any column (nothing there can have a footer). | | C19 | `Document::parse_recovering` of several documents (one `Document` holding more than one) | It explains what it navigates: the first document's comments (the others through `Stream`); header and footer are the first document's. | | C20 | Error recovery | Section 5: skipped bytes at document level → footer; unpaired recovered items read as keys; owners are the nodes navigation presents (never a recovery placeholder). | # Migrating from `serde_yaml` `serde_yaml` is archived; `serde_yaml_ng` and `serde_norway` are its maintained forks. This guide moves code from any of them to `oneyaml`. Where it says `serde_yaml`, it means all three: they share one code base (libyaml-based, version 0.9 API). Every example below compiles and runs as a test (the guide is a doc test of the unpublished `oracles` crate: `cargo test -p oracles --doc`). Every claim about how `oneyaml` and `serde_yaml` differ is checked by the compat differential, `crates/oracles/tests/compat_differential.rs`, against `serde_yaml_ng` 0.10.0 (see [Divergences](#divergences)). ## Contents 1. [The `use` swap](#the-use-swap) 2. [`Compat::serde_yaml()`](#compatserde_yaml) 3. [`Value`](#value) 4. [Errors](#errors) 5. [Duplicate keys](#duplicate-keys) 6. [Merge keys (`<<`)](#merge-keys-) 7. [YAML 1.1 behaviours](#yaml-11-behaviours) 8. [Enums](#enums) * [Tagged scalar variants: `resolve_tagged_variant_content`](#tagged-scalar-variants-resolve_tagged_variant_content) 9. [`from_value` / `to_value`, `Mapping`, `Sequence`](#from_value--to_value-mapping-sequence) 10. [Several documents](#several-documents) 11. [Limits](#limits) 12. [Divergences](#divergences) 13. [Not (yet) in `oneyaml`](#not-yet-in-oneyaml) ## The `use` swap The entry points keep `serde_yaml`'s names, so most code migrates by replacing the crate name: | `serde_yaml` | `oneyaml` | |---|---| | `from_str`, `from_slice`, `from_reader` | the same, plus `*_with(.., &Settings)`; `from_slice` needs `DeserializeOwned` (see below) | | `to_string`, `to_writer` | the same, plus `*_with(.., &Settings)` | | `from_value`, `to_value` | the same, plus `*_with` | | `Value`, `Mapping`, `Number`, `Sequence` | the same names (see [`Value`](#value)) | | `value::Tag`, `value::TaggedValue` | `Tag`, `TaggedValue` | | `Deserializer::from_str` (iterate for several documents) | the same | | `Serializer::new(writer)` | the same; `Serializer::with_settings` | | `Error`, `Location` | the same names; `Error` also has a `code()` | ```rust use serde::{Deserialize, Serialize}; #[derive(Debug, PartialEq, Serialize, Deserialize)] struct Config { name: String, port: u16, tags: Vec, } let src = "name: web\nport: 8080\ntags:\n- api\n- internal\n"; let config: Config = oneyaml::from_str(src)?; // was serde_yaml::from_str assert_eq!(config.port, 8080); // oneyaml's own layout indents sequences under their key ... assert_eq!( oneyaml::to_string(&config)?, "name: web\nport: 8080\ntags:\n - api\n - internal\n" ); // ... and `Compat::serde_yaml()` writes what serde_yaml writes. let compat = oneyaml::Compat::serde_yaml(); assert_eq!(oneyaml::to_string_with(&config, &compat)?, src); # Ok::<(), oneyaml::Error>(()) ``` `from_slice` decodes its bytes first (UTF-8, UTF-16, UTF-32, with a BOM), so unlike `serde_yaml::from_slice` it cannot lend `&str` fields out of the input: its target is `DeserializeOwned`. To borrow, check the bytes are UTF-8 (`std::str::from_utf8`) and use `from_str`. Plain scalars read into string fields with their source text, as in `serde_yaml`: `port: 8080` reads into a `String` as `"8080"`, and `version: 1.10` as `"1.10"` (not `"1.1"`). A quoted scalar never reads into a number or bool (`'8080'` into `u16` is an error), again as in `serde_yaml`. ## `Compat::serde_yaml()` A preset `Settings` value for migration. Everything it does is an opt-in departure from YAML 1.2, off by default: | | Default | `Compat::serde_yaml()` | |---|---|---| | Duplicate keys | error `Y1013` | last wins, warning `Y1014` | | `0b1010`, `-0x10`, `+0x10` | strings (not YAML 1.2) | integers, as `serde_yaml` reads them (`yaml11_int_forms`) | | `!Square 2.0` into an enum | a tagged string: no `f64` content | `Square(2.0)`, read and written as `serde_yaml` does (`resolve_tagged_variant_content`, see [Enums](#enums)) | | Output layout | sequences indented under their key | `serde_yaml`'s layout, byte for byte where possible (`Layout::SerdeYaml`) | | Limits | `Limits::default()` | `Limits::default()` (presets never change limits) | Refine a preset like any `Settings`: ```rust use oneyaml::{Compat, DuplicateKeys, Value}; let strict = Compat::serde_yaml().duplicate_keys(DuplicateKeys::Error); assert!(oneyaml::from_str_with::("a: 1\na: 2\n", &strict).is_err()); let v: Value = oneyaml::from_str_with("mask: 0b1010\n", &Compat::serde_yaml())?; assert_eq!(v["mask"].as_u64(), Some(10)); let v: Value = oneyaml::from_str("mask: 0b1010\n")?; // YAML 1.2: a string assert_eq!(v["mask"].as_str(), Some("0b1010")); # Ok::<(), oneyaml::Error>(()) ``` Output follows `serde_yaml`'s layout except where `serde_yaml`'s output does not read back correctly. The layout differences are listed, with reasons, in `crates/oracles/tests/serde_yaml_compat.rs` (for example, U+2028 in a single-quoted string); the differences in how strings are written (which ones are quoted) and in how input is read are listed in `crates/oracles/tests/compat_differential.rs` (see [Divergences](#divergences)). ## `Value` `oneyaml::Value` has the same variants as `serde_yaml::Value` (`Null`, `Bool`, `Number`, `String`, `Sequence`, `Mapping`, `Tagged`), mapping keys of any `Value`, and insertion order. The differences: - **Tags are kept.** `serde_yaml` keeps only local tags (`!thing`); it drops `!!set`, `!!omap`, `!!binary`, and tags from `%TAG` directives. oneyaml keeps every tag the schema does not resolve. Core tags that the schema does resolve (`!!str`, `!!int`, `!!float`, `!!bool`, `!!null`, `!!map`, `!!seq`) decide the type and are not kept. A tag's text is the full tag: `Tag::new("!x")` and `Tag::new("x")` are different tags (in `serde_yaml` they are equal). - **A custom-tagged scalar is a string.** `!celsius 21` is the tagged *string* `"21"`: a custom tag gives the schema no licence to read it as a number. `serde_yaml` resolves the content (`21`). - **The non-specific tag `!` makes a string** (YAML 1.2 §6.9.1): `! 12` is the string `"12"`, not a value tagged `!`. - **Numbers of any size.** `Number` holds any integer, beyond 64 and 128 bits; `serde_yaml`'s `Value` refuses integers wider than 64 bits. Equality is by value, whatever the radix: `0x10 == 16`. - **`Sequence` is a type, not `Vec`.** It has non-panicking `get`/`push`/`insert_at`/`remove_at`/`iter`/`len`, and `as_slice()`, `into_vec()`, `From>` to reach a `Vec`. - **Indexing never panics, and there is no `IndexMut`.** `v["a"]["b"]` reads (a missing entry is `Null`, as in `serde_yaml`); to write, use `get_mut`, `as_mapping_mut`, `as_sequence_mut`. ```rust use oneyaml::{Mapping, Number, Tag, Value}; let v: Value = oneyaml::from_str( "set: !!set {a: ~}\ntemp: !celsius 21\nhex: 0x10\nbig: 123456789012345678901234567890\n", )?; let set = v["set"].as_tagged().expect("kept"); assert_eq!(set.tag, "tag:yaml.org,2002:set"); // as serde_yaml's `Tag == str` let temp = v["temp"].as_tagged().expect("kept"); assert_eq!((temp.tag.to_string(), temp.value.as_str()), ("!celsius".to_owned(), Some("21"))); assert_eq!(temp.tag, Tag::new("!celsius")); assert_eq!(v["hex"], Value::Number(Number::from(16))); assert_eq!(v["big"].as_number().map(|n| n.to_string()).as_deref(), Some("123456789012345678901234567890")); assert!(v["missing"]["deeper"].is_null()); // Writing: through `get_mut` / `as_mapping_mut`. let mut v = v; if let Some(m) = v.as_mapping_mut() { m.insert(Value::from("added"), Value::from(true)); } *v.get_mut("hex").expect("present") = Value::from("sixteen"); assert_eq!(v["hex"].as_str(), Some("sixteen")); assert_eq!(v["added"].as_bool(), Some(true)); assert_eq!(v.as_mapping().map(Mapping::len), Some(5)); # Ok::<(), oneyaml::Error>(()) ``` ## Errors `oneyaml::Error` is one opaque type, like `serde_yaml::Error`. It also carries a stable code (`Y####`, never reused; `Y0xxx` syntax, `Y1xxx` schema, `Y2xxx` limits, `Y3xxx` serde), the path to the failing node, a span, and warnings. `Display` is compact; `render(src)` gives a rustc-style snippet (no dependencies); with the `miette` feature, `Error` is a `miette::Diagnostic`. ```rust use oneyaml::{Value, code}; let src = "a: 1\na: 2\n"; let e = oneyaml::from_str::(src).unwrap_err(); assert_eq!(e.code(), code::DUPLICATE_KEY); assert_eq!(e.to_string(), "duplicate key `a` (2:1)"); let loc = e.location().expect("located"); assert_eq!((loc.line(), loc.column()), (2, 1)); // 1-based, as in serde_yaml assert!(e.render(src).contains("first defined here")); #[derive(serde::Deserialize, Debug)] struct Server { port: u16 } let e = oneyaml::from_str::("port: 99999\n").unwrap_err(); assert_eq!(e.path().map(|p| p.to_string()).as_deref(), Some("port")); ``` `from_str` ignores warnings (as `serde_yaml` has none). To see them, use `from_str_with_diagnostics`: ```rust use oneyaml::{Compat, Value}; let (v, warnings) = oneyaml::from_str_with_diagnostics::("a: 1\na: 2\n", &Compat::serde_yaml())?; assert_eq!(v["a"].as_u64(), Some(2)); assert_eq!(warnings[0].code, oneyaml::code::DUPLICATE_KEY_LAST_WINS); # Ok::<(), oneyaml::Error>(()) ``` ## Duplicate keys YAML requires unique keys, so a duplicate is an error by default (`Y1013`, with a "first defined here" label). `serde_yaml` also refuses duplicates in `Value` and in structs, but its typed maps (`HashMap`, `BTreeMap`) keep the last value. `Compat::serde_yaml()` (or `.duplicate_keys(DuplicateKeys::LastWins)`) keeps the last value everywhere — `Value`, structs, and maps — and always records a warning (`Y1014`). ## Merge keys (`<<`) YAML 1.2 has no merge keys: by default `<<` is an ordinary string key, as in `serde_yaml`. `serde_yaml` merges only through `Value::apply_merge`, so typed reads need a detour (`from_str::`, `apply_merge`, `from_value`), which loses the source text of scalars (a `1` then no longer reads into a `String` field). oneyaml merges while reading, into any target, with one setting: ```rust use serde::Deserialize; use std::collections::BTreeMap; use oneyaml::Settings; #[derive(Deserialize)] struct Service { image: String, restart: String, environment: BTreeMap } #[derive(Deserialize)] struct Compose { services: BTreeMap } let src = "\ x-common: &common restart: unless-stopped environment: {TZ: UTC, WORKERS: 4} services: web: <<: *common image: nginx "; let c: Compose = oneyaml::from_str_with(src, &Settings::new().merge_keys(true))?; assert_eq!(c.services["web"].restart, "unless-stopped"); assert_eq!(c.services["web"].environment["WORKERS"], "4"); # Ok::<(), oneyaml::Error>(()) ``` Merges through aliases count toward the alias budget (`Limits`). In a `Value`, the mapping's own keys come first and merged keys follow (`apply_merge` moves the mapping's last key into the `<<` slot instead). ## YAML 1.1 behaviours `serde_yaml` resolves plain scalars close to the YAML 1.2 Core schema, as oneyaml does: `yes`, `no`, `on`, `off` are strings; `12:30` is a string; `1_000` is a string; `0o17` is 15. Three differences remain (see [Divergences](#divergences)): - **A leading zero**: YAML 1.2 Core reads `0644` as the decimal integer 644; `serde_yaml` reads it as the string `"0644"`. Quote such values (`'0644'`) if they are strings, or write `0o644` for octal. - `-0o17` is an integer in `serde_yaml` and a string in oneyaml (a signed `0o` is neither YAML 1.1 nor 1.2). - A float that overflows, such as `1e400`: YAML 1.2 Core reads it as infinity; `serde_yaml` reads it as the string `"1e400"`. (Written as a string, oneyaml quotes it, `'1e400'`, where `serde_yaml` writes it plain.) Reading `debug: yes` into a `bool` field is therefore an error (`Y3000`). The error says why: ``invalid type: string "yes", expected a boolean; YAML 1.2 reads `yes` as a string (its booleans are `true` and `false`)``, with help to write `true` or to turn on the 1.1 booleans below, and a suggested fix that writes `true`. Whatever the settings, oneyaml writes a string so that it reads back as the same string under the settings it was written with: with `yaml11_booleans(true)`, the string `no` is written `'no'`; with `sexagesimal(true)`, `12:30` is written `'12:30'` (in both layouts). Files written for YAML 1.1 readers (PyYAML, older tools) need the 1.1 behaviours, each a named setting, all together in `Compat::yaml_1_1()` (checked against PyYAML in `crates/oracles/tests/compat_yaml11.rs`): ```rust use oneyaml::{Compat, Settings, Value}; let src = "enabled: yes\nmode: 0644\ntime: 1:30\nsize: 1_000\nmask: 0b1010\n"; let v: Value = oneyaml::from_str_with(src, &Compat::yaml_1_1())?; assert_eq!(v["enabled"].as_bool(), Some(true)); // yaml11_booleans assert_eq!(v["mode"].as_u64(), Some(0o644)); // legacy_octal assert_eq!(v["time"].as_u64(), Some(90)); // sexagesimal assert_eq!(v["size"].as_u64(), Some(1000)); // digit_separators assert_eq!(v["mask"].as_u64(), Some(10)); // yaml11_int_forms // One behaviour at a time: let v: Value = oneyaml::from_str_with(src, &Settings::new().yaml11_booleans(true))?; assert_eq!(v["enabled"].as_bool(), Some(true)); assert_eq!(v["mode"].as_u64(), Some(644)); # Ok::<(), oneyaml::Error>(()) ``` `%YAML 1.1` documents are read as YAML 1.2 with a warning; `Settings::honor_version_directive(true)` switches such documents to `Compat::yaml_1_1()`. ## Enums `serde_yaml` 0.9 writes and reads enum variants as tags (`!Circle {r: 1}`); it refuses the map form (`Circle: {r: 1}`) unless the field uses `serde_yaml::with::singleton_map`. oneyaml reads both forms. By default it writes a tag for a variant around a collection or a string (`!Circle`, `!Named x`), and the map form for a variant around a number, bool, or null (`Square: 2.0`), because under YAML 1.2 a tagged scalar is a string (see `Value`): ```rust use serde::{Deserialize, Serialize}; #[derive(Debug, PartialEq, Serialize, Deserialize)] enum Shape { Unit, Circle { r: f64 }, Pair(i32, i32), Named(String), Square(f64) } let tags = "[Unit, !Circle {r: 1.5}, !Pair [1, 2], !Named x]"; let maps = "[Unit, Circle: {r: 1.5}, Pair: [1, 2], Named: x]"; let a: Vec = oneyaml::from_str(tags)?; let b: Vec = oneyaml::from_str(maps)?; assert_eq!(a, b); assert_eq!(oneyaml::to_string(&Shape::Circle { r: 1.5 })?, "!Circle\nr: 1.5\n"); assert_eq!(oneyaml::to_string(&Shape::Square(2.0))?, "Square: 2.0\n"); // By default a tagged number is a tagged *string* (see `Value`), so // `!Square 2.0`, which serde_yaml writes, does not read into an `f64`: assert!(oneyaml::from_str::("!Square 2.0").is_err()); # Ok::<(), oneyaml::Error>(()) ``` The variant name of a tag is its text without the leading `!`. A tag written with a `%TAG` handle (`%TAG !e! tag:example.com,2000:` then `!e!point`) names the variant `tag:example.com,2000:point`; give the variant that name with `#[serde(rename = "…")]`. When the tag names one of the enum's variants, the read costs only the tag's own suffix, however long the `%TAG` prefix is. When it names none (a `#[serde(other)]` variant, a hand-written visitor, or `deserialize_any`), the name is handed over as one string: prefix and suffix are copied, at O(the tag's length) per use. ### Tagged scalar variants: `resolve_tagged_variant_content` `serde_yaml` writes a newtype variant around a number, bool, or null as a tagged scalar (`!Square 2.0`, `!Enabled true`, `!Maybe null`). YAML 1.2 leaves the meaning of a local tag such as `!Square` to the application (§6.9.2), and oneyaml's schema gives it none: the node is the tagged string `2.0`. `Settings::resolve_tagged_variant_content(true)`, on in `Compat::serde_yaml()` only, is the named, opt-in departure that lets the application (your enum) decide: * **Reading.** When a *typed enum* reads a *plain* scalar with a *local* tag (`!Name`) as its variant `Name`, the content is resolved as an untagged plain scalar under the Core schema (with the document's YAML 1.1 settings, e.g. `yaml11_int_forms`): `!Square 2.0` is `Square(2.0)`, `!Enabled true` is `Enabled(true)`. A string target still gets the text (`!Named 123` is `Named("123")`). * **Unchanged.** `Value` keeps `Tagged("!Square", String("2.0"))`, and untyped reads (`deserialize_any`) see the tagged string. Quoted content stays a string (`!Square '2.0'` is text), and global or `%TAG`-expanded tags never resolve. `from_value_with` and `NodeRef::deserialize` read with the same setting (a `Value` keeps no scalar style, so `from_value_with` resolves the content of a tagged string whatever its quoting was: see [`from_value`](#from_value--to_value-mapping-sequence)). * **Writing.** Under `Layout::SerdeYaml` read back with the setting (as in `Compat::serde_yaml()`), such variants are written `!Name value`, byte for byte as `serde_yaml` writes them; without the setting they keep the map form, which reads back. `to_value` builds the map form in every case (`Value` has no enums). ```rust use oneyaml::{Compat, Settings, Value}; use serde::{Deserialize, Serialize}; #[derive(Debug, PartialEq, Serialize, Deserialize)] enum Shape { Square(f64), Enabled(bool), Named(String) } let compat = Compat::serde_yaml(); let shapes: Vec = oneyaml::from_str_with("[!Square 2.0, !Enabled true, !Named 123]", &compat)?; assert_eq!( shapes, [Shape::Square(2.0), Shape::Enabled(true), Shape::Named("123".into())] ); assert_eq!(oneyaml::to_string_with(&Shape::Square(2.0), &compat)?, "!Square 2.0\n"); // Only typed enums change: the `Value` is still the tagged string. let v: Value = oneyaml::from_str_with("!Square 2.0", &compat)?; assert_eq!(v.untag_ref().as_str(), Some("2.0")); // Quoted content is text. assert!(oneyaml::from_str_with::("!Square '2.0'", &compat).is_err()); // The setting alone, on top of any other settings: let s = Settings::new().resolve_tagged_variant_content(true); assert_eq!(oneyaml::from_str_with::("!Square 2", &s)?, Shape::Square(2.0)); # Ok::<(), oneyaml::Error>(()) ``` `serde_yaml::with::singleton_map` (and `singleton_map_recursive`) are generic serde adapters, so they keep working with oneyaml if you keep the dependency for them; oneyaml has no `with` module of its own. Without them, oneyaml already reads the map form they write. ## `from_value` / `to_value`, `Mapping`, `Sequence` ```rust use std::collections::BTreeMap; use oneyaml::{Mapping, Sequence, Value}; let v = oneyaml::to_value(&BTreeMap::from([("port", 8080)]))?; let back: BTreeMap = oneyaml::from_value(v.clone())?; assert_eq!(back["port"], 8080); let mut m = Mapping::new(); m.insert(Value::from("k"), Value::from(1)); assert_eq!(m.get(&Value::from("k")), Some(&Value::from(1))); assert_eq!(m.get_str("k"), Some(&Value::from(1))); assert!(m.contains_key(&Value::from("k"))); assert_eq!(m.remove(&Value::from("k")), Some(Value::from(1))); let mut s = Sequence::from(vec![Value::from(1)]); s.push(Value::from(2)); assert_eq!(s.get(1), Some(&Value::from(2))); assert_eq!(s.get(9), None); // never panics let plain: Vec = s.into_vec(); assert_eq!(plain.len(), 2); # Ok::<(), oneyaml::Error>(()) ``` Two differences from `from_str`, both because a `Value` keeps only the meaning of a scalar, not how it was written: * As in `serde_yaml`, a `Value` has no source text, so `from_value` reads a number into a `String` field only if it was a string (a `Value` read from `port: 8080` holds the number 8080). * Under `resolve_tagged_variant_content` (see [Enums](#tagged-scalar-variants-resolve_tagged_variant_content)), `from_value_with` resolves the content of every tagged string a typed enum reads, quoted or not: `from_str_with` refuses `!Square '2.0'` as a `Square(f64)`, but the `Value` read from it, `Tagged("!Square", "2.0")`, is the same as the one read from `!Square 2.0`, and `from_value_with` reads it as `Square(2.0)`. It resolves to a bool or a number only, never to null: `!Opt 'null'` and `!Opt null` are the same `Value`, so `from_value_with` reads both as `Opt(Some("null"))` into an `Option` (text is never lost), and `!Maybe null` into an `Option` is an error there (`from_str_with` gives `Maybe(None)`). ```rust use oneyaml::{Compat, Value}; #[derive(Debug, PartialEq, serde::Deserialize)] enum Shape { Square(f64) } let compat = Compat::serde_yaml(); assert!(oneyaml::from_str_with::("!Square '2.0'", &compat).is_err()); let v: Value = oneyaml::from_str_with("!Square '2.0'", &compat)?; assert_eq!(oneyaml::from_value_with::(v, &compat)?, Shape::Square(2.0)); #[derive(Debug, PartialEq, serde::Deserialize)] enum E { Opt(Option) } // Never resolved to null through a `Value`: let v: Value = oneyaml::from_str_with("!Opt 'null'", &compat)?; assert_eq!(oneyaml::from_value_with::(v, &compat)?, E::Opt(Some("null".into()))); # Ok::<(), oneyaml::Error>(()) ``` ## Several documents As in `serde_yaml`, `from_str` reads exactly one document (`Y3009` for more), and iterating a `Deserializer` reads each: ```rust use serde::Deserialize; let docs: Vec = oneyaml::Deserializer::from_str("a: 1\n---\nb: 2\n") .map(oneyaml::Value::deserialize) .collect::>()?; assert_eq!(docs.len(), 2); // A stream with no document (empty, or only comments) has zero documents // (YAML 1.2 §9.2); serde_yaml's iterator yields one null document. assert_eq!(oneyaml::Deserializer::from_str("# nothing\n").count(), 0); // `from_str` agrees with serde_yaml: an empty input is null. assert!(oneyaml::from_str::("")?.is_null()); # Ok::<(), oneyaml::Error>(()) ``` To write several documents, call `serialize` once per document on one `Serializer` (it writes the `---` separators), as with `serde_yaml`. ## Limits `serde_yaml` has a fixed nesting limit of 128. oneyaml's limits are settings: depth, alias expansion ("billion laughs"), document size, scalar length, and document count. `Limits::default()` suits configuration files (depth 1,024); for input from the network use `Limits::untrusted()` (depth 128, a tighter alias budget, 16 MiB documents). Compat presets never change limits. ```rust use oneyaml::{Compat, Limits, Value}; let untrusted = Compat::serde_yaml().limits(Limits::untrusted()); let bomb = "a: &a [x, x, x, x, x, x, x, x, x, x]\n\ b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]\n\ c: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b]\n\ d: &d [*c, *c, *c, *c, *c, *c, *c, *c, *c, *c]\n\ e: &e [*d, *d, *d, *d, *d, *d, *d, *d, *d, *d]\n\ f: [*e, *e, *e, *e, *e, *e, *e, *e, *e, *e]\n"; let e = oneyaml::from_str_with::(bomb, &untrusted).unwrap_err(); assert_eq!(e.code(), oneyaml::code::ALIAS_BUDGET_EXCEEDED); assert!(e.to_string().contains("alias")); ``` Recursive Rust types are bounded separately by `Limits::max_typed_depth` (128 by default), because serde recurses once per level. ## Divergences Where `oneyaml` with `Compat::serde_yaml()` and `serde_yaml_ng` disagree, the reason is one of these. The list is exact: `crates/oracles/tests/compat_differential.rs` reads and writes the yaml-test-suite, the bench corpus, and a migration corpus of Kubernetes, GitHub Actions, docker-compose and config files with both libraries, writes some 700 scalar-looking strings (and serde_yaml_ng must read each of our outputs back as the same string), and fails on any disagreement not listed there (and on a listed one that stops occurring). Output-layout divergences are listed in `crates/oracles/tests/serde_yaml_compat.rs`. | Divergence | What differs | Why | |---|---|---| | `rejects_valid` | `serde_yaml` refuses valid YAML 1.2: tabs as separation, `:` next to flow keys, empty keys, unknown or 1.3 directives, bare `...`, multi-line flow keys, `!!null ''` | libyaml is a YAML 1.1 parser; oneyaml passes the yaml-test-suite (correctness first) | | `accepts_invalid` | `serde_yaml` reads input the suite marks invalid, or reports the error one document late | correctness first | | `misreads_valid` | both read it, `serde_yaml`'s value is not the spec's (`?foo` in flow, anchors ending in `:`, trailing-space lines in block scalars) | correctness first | | `tags_kept` | `!!set`, `!!omap`, `!!binary`, global tags are kept | `Value` keeps tags | | `non_specific_tag` | `! 12` is the string `"12"` | YAML 1.2 §6.9.1 | | `tagged_scalar_value` | an untyped read (`Value`) keeps `!Square 2.0` as the tagged string; typed enums agree (`resolve_tagged_variant_content`) | a custom tag gives the schema no licence to resolve | | `enum_map_form` | oneyaml reads `Circle: {r: 1}`; `serde_yaml` needs `singleton_map` | reading more forms is compatible | | `empty_stream` | an empty stream has zero documents for the `Deserializer` iterator | YAML 1.2 §9.2 | | `duplicate_keys` | last wins (with a warning) in `Value` and structs | `Compat::serde_yaml()` sets `DuplicateKeys::LastWins`; oneyaml always warns about the duplicate | | `leading_zero_int` | `0644` is 644, not `"0644"` | YAML 1.2 Core §10.3.2 | | `signed_0o` | `-0o17` is a string | neither YAML 1.1 nor 1.2 | | `float_overflow` | `1e400` is infinity, not a string | Core float pattern | | `wide_integers` | integers beyond 64 bits read | `Number` holds any integer | | `recursion_limit` | nesting deeper than 128 reads | `max_depth` 1,024 by default; presets never change limits | | `merge_order` | key order after a merge | `apply_merge`'s swap-remove vs own-keys-first | | `merge_via_value` | a merged `1` reads into a `String` | merging while reading keeps source text | | `merge_key_quoted` | the strings `<<` and `=` are written quoted (`'<<': ...`), in every layout | written plain, a string key `<<` would merge into its parent in every merge-key reader | | `core_reads_a_number` | the string `1e400` or a wide `0x...` is written quoted | YAML 1.2 reads the plain form as a number; output must read back | | `root_indicator`, `serde_yaml_write_error` | output | `serde_yaml`'s output would not read back, or it fails to write | ## Not (yet) in `oneyaml` - `Deserializer::from_slice` / `from_reader` (use `from_slice`/`from_reader` for one document, or read into a `String` and use `Deserializer::from_str`). - `Mapping::entry` and `IndexMut` for `Value` (use `get_mut`, `as_mapping_mut`, `Mapping::insert`). - `Value::apply_merge` (use `Settings::merge_keys(true)` when reading). - A `with` module (`singleton_map`, `singleton_map_recursive`): keep `serde_yaml`'s, which are format-independent. # How oneyaml compares This page compares oneyaml with the other Rust YAML libraries you are likely to consider. **We wrote it, so it is biased**: we have tried to be factual, to cite evidence for every claim, and to say plainly where the others are the better choice. If something here is wrong or out of date, please [open an issue](https://github.com/recursive-ai-oss/oneyaml-rust/issues/new/choose). Facts about other libraries are limited to what this project has measured or tested, at the versions named in [docs/performance.md](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#how-to-read-these-numbers) and [docs/conformance.md](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md#parsers-under-test). This page restates no benchmark number; it links to the tables. ## The libraries - **`serde_yaml_ng`** and **`serde_norway`**: maintained forks of the archived `serde_yaml`. serde reads and writes, a `Value` type, and the `serde_yaml` API that most Rust code using YAML was written against. They parse with `unsafe-libyaml` (`serde_norway`: its fork `unsafe-libyaml-norway`), a C-to-Rust translation of libyaml, a YAML 1.1 parser. - **serde-saphyr**: a serde layer, independent of the saphyr project, built on `granit-parser` (a fork of `saphyr-parser`). - **saphyr** (`saphyr`, `saphyr-parser`) and **yaml-rust2**: pure-Rust event parsers with their own document types; no serde layer of their own (saphyr's `saphyr-serde` is a placeholder). - **yaml-edit**: a lossless, comment-preserving YAML editor built on a rowan syntax tree; no serde layer. ## At a glance | | oneyaml | serde_yaml_ng / serde_norway | serde-saphyr | yaml-edit | |---|---|---|---|---| | serde read and write | yes | yes | yes | no | | Lossless editing that keeps comments | yes (`Document`) | no | no | yes | | One parser behind both serde and editing | yes | n/a | n/a | n/a | | JSON Patch, merge patch and JSON Pointer on the YAML text, keeping comments | yes (`Document::apply_patch`, `apply_merge_patch`, `get_pointer`) | n/a | n/a | see its docs | | YAML version read by default | 1.2.2, Core schema | 1.1 (libyaml) | see its docs | see its docs | | Merge keys (`<<`) in typed reads | opt-in setting `merge_keys` | only through `Value::apply_merge` | expanded by default | n/a | | Several documents in one stream | yes | yes | yes | see its docs | | `unsafe` in the parser | none (`forbid(unsafe_code)`) | yes (`unsafe-libyaml`, `unsafe-libyaml-norway`) | not checked here | not checked here | | yaml-test-suite | [conformance](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md) | libyaml's row in [conformance](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md#results-standard-criterion) | not measured | [conformance](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md#results-classification-and-round-trip) | Sources: the comparator table in [*Serde read*](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#serde-read) (documents, merge keys, value types), [*What each parser does*](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#what-each-parser-does) (what each parser produces) and [*Parsers under test*](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md#parsers-under-test). "See its docs" means we haven't verified it, not that it's missing. ## Correctness oneyaml, saphyr-parser and yaml-rust2 classify the yaml-test-suite the same way under the standard criterion; libyaml, the parser behind `serde_yaml_ng` and `serde_norway`, rejects some valid YAML 1.2 and accepts some invalid input; yaml-edit's parser is lenient by design and ships a separate validator. The counts, and why the saphyr and yaml-rust2 scores are a weaker claim than ours (their event APIs can't express everything the suite checks), are in [docs/conformance.md](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md). Every difference between oneyaml's `Compat::serde_yaml()` reading and `serde_yaml_ng`'s is listed, with its reason, in the [migration guide](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/migrating-from-serde-yaml.md#divergences). ## Performance The headline comparison is CPU instructions (valgrind), which are deterministic; wall-clock times are measured too, and reported where they disagree. Each serde row is compared with the fastest feature-complete serde library on that row, and `Document` rows with yaml-edit. **Where oneyaml is ahead:** serde writes, against every feature-complete comparator, in instructions on every workload and in wall-clock time on all but one; typed serde reads in instructions on every workload; `Document` parsing against yaml-edit in instructions on every workload (in wall-clock time it is at parity on the docker-compose-shaped file and faster on the others); and the edit script against yaml-edit on every workload above a few kilobytes, in instructions and in wall-clock time. **Where the others win:** - **Edits on small documents.** Below a few kilobytes, yaml-edit's edit script costs fewer instructions and less wall-clock time: oneyaml pays a fixed cost per edit to decide which entry owns each nearby comment and to check that the edited text still reads as the tree says. On every larger file in the benchmark, oneyaml's script is the cheaper one, and it costs about the same on a 10 MB file as on a 100 KB one of the same shape. A patch also reads the keys of each mapping it looks into, once per patch, so its cost grows with the width of those mappings. - **Some wall-clock serde reads.** In wall-clock time, `serde_yaml_ng` reads the OpenAPI-shaped file into typed structs faster, although oneyaml uses fewer instructions there; across the full tables, `serde_yaml_ng` or `serde_norway` is also faster on most reads into structs that use a few fields and on some `Value` reads, and level on two typed reads. If wall-clock read speed is what you are optimising, measure on your own files. - **Some serde reads in instructions.** Reads into structs that use a few fields take a few percent more instructions in oneyaml than in `serde_norway` on the OpenAPI-shaped files, and `Value` reads of the docker-compose-shaped file with merge keys are at parity with `serde_yaml_ng`. - **One wall-clock serde write.** serde-saphyr writes a `Value` of the docker-compose-shaped file slightly faster in wall-clock time, although oneyaml uses fewer instructions. - **Partial libraries.** serde_yaml2 is cheaper on some reads, but it reads only the first document and has no merge keys, so it isn't ranked. The tables: the [benchmark summary](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#results-at-a-glance), which names every row where oneyaml is slower, and every workload in [docs/performance.md](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md), with the commands to reproduce them. ## Which one to choose - **You read and write config files with serde, and also need to edit some without losing comments:** oneyaml; that is the case it is built for, one interpretation for both. - **You have working `serde_yaml` code and no reason to change:** stay on `serde_yaml_ng` or `serde_norway`. If you want YAML 1.2, no `unsafe`, or diagnostics with codes and fixes, the [migration guide](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/migrating-from-serde-yaml.md) starts with a `use` swap. - **You only edit YAML, mostly small files, and don't need serde:** yaml-edit is a reasonable choice; oneyaml's edits refuse, with a named error, a few comment layouts it can't edit safely (listed on `Document`). - **You need an event parser or your own document model:** `oneyaml-syntax` exposes a lossless event stream and syntax tree; saphyr-parser and yaml-rust2 give lighter events without comments or white space. # Performance What every layer of oneyaml costs against the other Rust YAML libraries, on a fixed, generated corpus of configuration-file shapes. Coming from the chart in the [README](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/oneyaml/README.md)? [Results at a glance](#results-at-a-glance) has the same comparison bar by bar and as tables (five workloads against the fastest feature-complete serde library or yaml-edit, in instructions and in wall-clock time) and says where oneyaml wins and where it loses; [Regenerate](#regenerate) has the commands that produce every table here. This page covers: - **the parser**: `oneyaml-syntax`'s lossless event stream against saphyr-parser, yaml-rust2, libyaml and yaml-edit, and its syntax tree ([Parser](#parser)); - **serde**: reading ([Serde read](#serde-read)) and writing ([Serde write](#serde-write)) against serde_yaml_ng, serde_norway, serde-saphyr and serde_yaml2, with serde_json as a reference; - **`Document`**: parse, emit and edits against yaml-edit ([`Document`](#document)). ## How to read these numbers - **The primary metric is CPU instructions**, counted by valgrind (callgrind), per input byte (Ir/B) or per operation. Instruction counts are deterministic for a given build: repeated runs of the same binary give the same count (to within 0.1% where a hash map with per-process random keys is involved), so a difference between two cells of one run is real, not noise. Rebuilding (a different toolchain, or a dependency rebuilt with other code generation) can shift a library's counts by a few percent, so compare numbers from one run, as every table here does. Counts cover user-space work only, not cache misses, page faults or kernel time. - **Allocations** (blocks, bytes, peak heap) are counted by valgrind DHAT and are deterministic too. - **Wall-clock times are informational.** They were taken on a shared, loaded 16-core desktop (each wall-clock table prints the load average), so read them as ratios and orders of magnitude. Where wall-clock and instructions point in different directions, both are shown. - **Ratios are oneyaml ÷ competitor** in these tables, so **below 1.0× oneyaml is cheaper**. The summary tables in [Results at a glance](#results-at-a-glance) and the README chart invert them into "× faster" (competitor ÷ oneyaml, higher is better). - **Serde ratios are against the fastest feature-complete library on each row**: serde_yaml_ng, serde_norway and serde-saphyr all support aliases, multi-document streams and merge keys. serde_yaml2 does not (no multi-document API, no merge keys); its column and ratio are shown, marked *partial, no target*, but it never decides a verdict. - **Every competitor's output is checked before it is measured** (the correctness-guard tables): a library that rejects an input, gives a different result, or can't do the equivalent work gets a `rejects`, `differs` or `n/a` cell instead of a number. The run's provenance (the summary's method notes are read from it): | Measured | | |---|---| | Revision | oneyaml 1.2.0 release tree | | Date | 2026-10-21 | | CPU | AMD Ryzen 9 7950X3D 16-Core Processor | | OS | Ubuntu 26.04 LTS (x86_64) | | rustc | rustc 1.97.1 (8bab26f4f 2026-07-14) | | Build | `release` profile | | valgrind | valgrind-3.27.1 | | Comparators | serde_yaml_ng 0.10.0, serde_norway 0.9.42, serde-saphyr 1.3.0, serde_yaml2 0.1.3, serde_json 1.0.151, yaml-edit 0.3.2, saphyr-parser 0.1.0, yaml-rust2 0.13.0, unsafe-libyaml 0.2.11 | ## Results at a glance The numbers behind the README chart: five workloads for each operation, each serde row against the fastest feature-complete serde library on that row and each `Document` row against yaml-edit, the other lossless editor. The chart has one bar per row; the tables give the absolute cost of both libraries, the instruction ratio and the wall-clock ratio, and label every row where oneyaml is slower *slower*. Both are generated from the per-operation tables further down this page, so they always match them. Bar chart: how many times fewer CPU instructions oneyaml executes than each competitor (higher is better), against the fastest feature-complete YAML serde library on each workload for serde read and serde write, and yaml-edit for Document parse and Document edit script (insert + set + remove), on 5 workloads. oneyaml executes more instructions on 1 of 20 rows: Document edit script (insert + set + remove) compose-merge. The tables below the chart give every number. | serde read (typed) | vs | oneyaml Ir/B | competitor Ir/B | × faster | × faster (wall-clock) | |---|---|---|---|---|---| | gha-workflow (3.1 kB) | serde_yaml_ng | **231.1** | 292.2 | **1.3×** | **1.0×** | | compose-merge (2.4 kB) | serde_yaml_ng | **529.6** | 623.3 | **1.2×** | **1.0×** | | helm-values (100 kB) | serde_norway | **102.1** | 174.7 | **1.7×** | **1.1×** | | openapi-100k (100 kB) | serde_yaml_ng | **333.1** | 348.4 | **1.0×** | 0.80× — slower | | k8s-multi (1.1 MB) | serde_norway | **340.9** | 422.1 | **1.2×** | **1.1×** | | serde write (typed) | vs | oneyaml Ir/B | competitor Ir/B | × faster | × faster (wall-clock) | |---|---|---|---|---|---| | gha-workflow (3.1 kB) | serde-saphyr | **128.8** | 186.6 | **1.4×** | **1.5×** | | compose-merge (2.4 kB) | serde-saphyr | **236.7** | 330.2 | **1.4×** | **1.3×** | | helm-values (100 kB) | serde-saphyr | **32.2** | 42.4 | **1.3×** | **1.3×** | | openapi-100k (100 kB) | serde-saphyr | **218.6** | 305.6 | **1.4×** | **1.3×** | | k8s-multi (1.1 MB) | serde-saphyr | **142.5** | 206.9 | **1.5×** | **1.4×** | | `Document` parse | vs | oneyaml Ir/B | competitor Ir/B | × faster | × faster (wall-clock) | |---|---|---|---|---|---| | gha-workflow (3.1 kB) | yaml-edit | **286.0** | 417.5 | **1.5×** | **1.3×** | | compose-merge (2.4 kB) | yaml-edit | **444.0** | 512.9 | **1.2×** | **1.0×** | | helm-values (100 kB) | yaml-edit | **135.8** | 196.0 | **1.4×** | **1.5×** | | openapi-100k (100 kB) | yaml-edit | **399.2** | 449.0 | **1.1×** | **1.1×** | | k8s-multi (1.1 MB) | yaml-edit | **385.4** | 435.2 | **1.1×** | **1.1×** | | `Document` edit script (insert + set + remove) | vs | oneyaml Ir per script | competitor Ir per script | × faster | × faster (wall-clock) | |---|---|---|---|---|---| | gha-workflow (3.1 kB) | yaml-edit | **826,673** | 1,870,115 | **2.3×** | **2.5×** | | compose-merge (2.4 kB) | yaml-edit | 503,520 | **302,397** | 0.60× — slower | 0.46× — slower | | helm-values (100 kB) | yaml-edit | **1,192,304** | 11,824,702 | **9.9×** | **17×** | | openapi-100k (100 kB) | yaml-edit | **584,641** | 37,349,309 | **64×** | **53×** | | k8s-multi (1.1 MB) | yaml-edit | **559,559** | 1,786,870 | **3.2×** | **4.2×** | - **× faster** = the competitor's CPU instructions ÷ oneyaml's on the same input (higher is better; below 1.0× oneyaml is slower), counted by valgrind (callgrind): deterministic from run to run, not wall-clock time. Ir/B = instructions per input byte; the edit script counts one script of three edits (insert + set + remove) on the parsed document; lower is better, the better value in bold. - **× faster (wall-clock)** = the competitor's median time ÷ oneyaml's on the same host (for serde, from the median throughput); it varies more from run to run than the instruction count. oneyaml is slower in wall-clock time on 2 of 20 rows: serde read openapi-100k; Document edit script (insert + set + remove) compose-merge. The medians are equal on 2 rows: serde read gha-workflow and compose-merge. - **Measured** oneyaml 1.2.0 release tree on 2026-10-21 · AMD Ryzen 9 7950X3D 16-Core Processor · Ubuntu 26.04 LTS (x86_64) · rustc 1.97.1 (8bab26f4f 2026-07-14) · valgrind-3.27.1, against serde_yaml_ng 0.10.0, serde_norway 0.9.42, serde-saphyr 1.3.0, yaml-edit 0.3.2. Serde rows compare with the fastest feature-complete YAML serde library on that row (aliases, multi-document, merge keys), `Document` rows with yaml-edit, the other lossless editor; every output is checked for equivalent work. Inputs are generated in the shape of GitHub Actions, docker-compose, Helm, OpenAPI and multi-document Kubernetes files. **Where oneyaml wins** - **Parsing.** The lossless event parser uses fewer instructions per byte than saphyr-parser, yaml-rust2 and libyaml on every input, comment- and whitespace-heavy ones included, although it does more work: it also produces events for every comment, space and indicator. Against yaml-edit, the other lossless parser, it is cheaper on every input yaml-edit accepts ([Instructions](#instructions)). - **Serde writes.** `to_string` uses fewer instructions than every feature-complete writer on every input, typed and untyped ([Serde write](#serde-write)). - **Serde reads** use fewer instructions than the fastest feature-complete reader on every `typed` row, every `value` row but one (compose-merge, at parity) and most `sparse` rows, and stay within the 1.25× target on every row ([Serde read](#serde-read); the targets are in [Targets and results](#targets-and-results)). - **`Document`.** Parsing a `Document` costs fewer instructions than yaml-edit's parse on every input, although oneyaml's parse also checks the document's meaning (duplicate keys, merge keys, aliases) and yaml-edit's checks syntax only. Writing an unchanged document back is several times to an order of magnitude cheaper, byte-identical on every input, and a parsed document keeps less memory than yaml-edit's tree ([`Document`](#document)). - **Edits stay local.** The edit script on a root mapping costs about the same number of instructions on every input from 2.4 KB to 10 MB but helm-values, where yaml-edit's script grows with the document: openapi-10m, the openapi-100k shape at 100× the size, costs oneyaml about the same as openapi-100k ([Edits](#edits)). helm-values costs more per script because each edit there decides the owners of a long run of comment lines. The tables measure the size of the document, not the width of the edited mapping: a patch reads the keys of each mapping it looks at once per patch, so its cost does grow with the width of those mappings. - **Allocations.** The parser borrows from the input and decodes scalars on request, so it makes a handful of allocations per input where event parsers make one or more per event; serde reads into borrowing types stream with O(depth) state ([Allocations](#allocations), [serde allocations](#allocations-1)). - **No pathological input.** oneyaml's instruction count grows linearly with input size on every stress shape, including 512k levels of flow nesting, which saphyr, yaml-rust2 and yaml-edit reject ([Linear scaling](#linear-scaling)). **Where oneyaml loses or is at parity** - **Edits on documents below a few kilobytes** (compose-merge, 2.4 KB) cost more than yaml-edit's, in instructions and in wall-clock time: each edit has a fixed cost for deciding which entry owns each nearby comment and checking that the edited text still reads as the tree says, which exceeds yaml-edit's whole-document cost on a small file. On every larger input in the [Edits](#edits) tables oneyaml's script is the cheaper one, so among the measured inputs the loss is on the smallest file. - **In wall-clock time, typed serde reads of openapi-100k are slower** than serde_yaml_ng, although they use fewer instructions. Across the full table, serde reads are slower in wall-clock time on most `sparse` rows, on `value` reads of compose-merge, k8s-multi and openapi-100k and on that `typed` row, and at parity on `typed` reads of gha-workflow and compose-merge ([serde wall-clock](#wall-clock-informational-only-1)). If wall-clock read speed is what matters most to you, measure on your own data. - **`sparse` reads** (a struct that reads a few fields) of the OpenAPI inputs take a few percent more instructions than serde_norway, and k8s-manifest's are at parity with serde_yaml_ng: oneyaml still resolves and checks the content a struct ignores (duplicate keys, merge keys), so a typed read accepts exactly the documents a `Value` read accepts ([Instructions](#instructions-1)). - **`value` reads of the file with merge keys** (compose-merge) are at parity in instructions with serde_yaml_ng. - **In wall-clock time, parsing `anchors` is slower than libyaml and `flow-heavy` is at parity**, although both use fewer instructions ([Wall-clock](#wall-clock-informational-only)). - **In wall-clock time, `value` writes of compose-merge are slightly slower than serde-saphyr's**, although they use fewer instructions ([serde write wall-clock](#wall-clock-write-informational-only)). - **serde_yaml2** is cheaper than oneyaml on the rows it can run, and **serde_json** reading the equivalent JSON is several times cheaper than any YAML library; both do less work (see their columns). ## Targets and results The performance targets oneyaml holds itself to, and how the tables below measure them. "met" means every row of the measure is within the target. | Target | Measured by | Result | |---|---|---| | Typed serde reads (`from_str::`) ≤ 1.25× the instructions of the fastest feature-complete YAML serde library | [serde read instructions](#instructions-1), `typed` and `sparse` rows | met on every row | | `from_str::` ≤ 1.25× | [serde read instructions](#instructions-1), `value` rows | met on every row | | `to_string` ≤ 1.25× | [serde write instructions](#instructions-write) | met on every row; cheaper than every feature-complete writer | | `Document` parse at parity with yaml-edit | [`Document`](#document), parse | met on every row | | Unchanged `Document` emit at parity with yaml-edit | [`Document`](#document), emit | met on every row, byte-identical output | | Edit operations at parity with yaml-edit | [Edits](#edits), script | met, except on documents below a few kilobytes (compose-merge); see above | | Local edits never cost O(document) | [Edits](#edits), script instructions and wall-clock | met for the measured root-mapping script: about the same from 2.4 KB to 10 MB, and openapi-10m about the same as openapi-100k | | A parsed `Document` retains ≤ 8× its input (real-world inputs of 100 KB or more) | [`Document`](#document), memory | met; smaller files and dense synthetic shapes are reported, without a target | | Serde reads into borrowing types use O(depth + anchored content) memory | [serde allocations](#allocations-1), `sparse` rows | met | | Parsing is O(n) for every input | [Linear scaling](#linear-scaling) | met | ## Regenerate You need valgrind 3.27 or later on `PATH` (or pass `--valgrind PATH`) and, for the wall-clock tables, a machine as quiet as you can get. ```sh cargo build -p bench --release target/release/bench update docs/performance.md --timeout 300 # every table (~24 min with --jobs 4) target/release/bench update docs/performance.md --section wall # one section target/release/bench table scaling # print a table, don't write target/release/bench probe yaml-edit deep-flow 10 # one parser, one input target/release/bench serde-check serde_yaml2 value gha-workflow # one read-guard verdict target/release/bench ser-check serde-saphyr typed k8s-multi # one write-guard verdict (+ output bytes) target/release/bench probe serde:oneyaml:typed openapi-10m 1 # one serde read probe target/release/bench probe ser:oneyaml:typed openapi-10m 1 # one serde write probe target/release/bench probe doc:yaml-edit:script openapi-100k 1 # one Document probe target/release/bench readme # summary and README charts only, from the tables below ``` - **Tables come from the default release build**, the one users compile oneyaml and the comparators with, so every library's numbers are the ones its users get. `update` and `table` refuse any other build, and a build with an override they can see: an optimization level other than 3, any `CARGO_PROFILE_*` variable in the build's environment (the `bench` profile's included: `cargo build --profile bench` writes to `target/release` too), or `RUSTFLAGS`. They can't see overrides passed with `--config` or set in a Cargo config file, so don't measure with those. The `environment` table's `Build` row records the build. - **Comparing oneyaml with itself** (probe counts before and after a change) can use the `measure` profile instead: `cargo build -p bench --profile measure`, then `target/measure/bench probe …`. In a release build, how the compiler splits code into codegen units moves some counts by up to about 1% when unrelated code changes; the `measure` profile builds in a single codegen unit, which removes that. It also changes how every library is optimized: on every serde read row, serde_yaml_ng's counts rise by about 6% to 14% and serde-saphyr's by about 2% to 6%, while oneyaml's move by about 1% or less. That would flatter oneyaml's ratios, so `probe` and `time` run from it, and `update` and `table` don't. - Every table sits between `` markers. `update` rewrites only what's between them, one section at a time (every section, in document order, without `--section`), and writes the file after each section, so don't edit it while `update` runs. - `--jobs N` limits how many valgrind processes run at once (default: every core). `--timeout SECS` (default 100) kills a probe that runs too long; wall-clock children get a quarter of it. These tables used 300: at 100, libyaml's `deep-flow` at 100× and yaml-edit's `deep-flow` at 10× time out instead of finishing. - **The summary and the README chart are generated from this file.** After every `update`, and on its own with `bench readme` (which measures nothing), `bench` rewrites the `bench:summary` tables and chart in [Results at a glance](#results-at-a-glance), the chart and its link in the `bench:readme` region of `crates/oneyaml/README.md` and the `bench:chart` region of the repository `README.md`, and the charts `docs/img/bench-{summary,detail}-{light,dark}.svg`, from the `serde-instructions`, `ser-instructions`, `document` and `edits` tables, their wall-clock tables and the `environment` table. Every number in them is a cell of those tables or the ratio of two, so the READMEs can't disagree with this file. - The `environment` table records the run's revision, date, CPU, OS, rustc, build, valgrind and the comparator versions (from `Cargo.lock`). `update` re-records it whenever it re-measures a table the summary reads. `--revision TEXT`, `--revision-note TEXT` and `--date YYYY-MM-DD` override what it records. - **Releases pin the links.** The READMEs' chart URLs and links to this file name a git ref, `main` by default. A published crate's README is frozen at release, so a release runs `cargo run -p bench --release -- pin-links ` on the release commit to point every repository link of the published docs at the tag (`--dry-run` shows what would change), and `pin-links main` afterwards to point them back. How long each section took in this run (`--timeout 300 --jobs 4`): | Section | Time | |---|---| | `environment` | < 0.1 s | | `corpus` | 0.4 s | | `instructions` | 207 s (one yaml-edit `deep-flow` probe dominates) | | `allocations` | 112 s | | `collect` | 6 s | | `tree` | 14 s | | `scaling` | 305 s (the 300 s timeout of libyaml `deep-flow` at 100× dominates) | | `wall` | 111 s | | `wall-scaling` | 41 s | | `serde-check` | 5 s | | `serde-instructions` | 89 s | | `serde-allocations` | 99 s | | `serde-wall` | 63 s | | `ser-check` | 5 s | | `ser-instructions` | 71 s | | `ser-allocations` | 138 s | | `ser-wall` | 28 s | | `document` | 65 s | | `edits` | 63 s | | **total** | **23 min 42 s** | valgrind 3.27.1 was built from source (`https://sourceware.org/pub/valgrind/valgrind-3.27.1.tar.bz2`, sha256 `5d589152eb8071c02feab8ce6ab719e431a1fbc3e2b1700f5432632a8b9264dc`) with `./configure --prefix=$HOME/.local && make && make install`. ## Method **Instructions.** The bench binary runs itself as `bench probe ` under `valgrind --tool=callgrind --toggle-collect=*measured_drain*`. Only instructions executed while the measured function is on the stack are counted: the parse (or read, write, edit), consuming the whole output, and dropping it. Input generation and start-up are excluded. This is the `iai-callgrind`/`gungraun` technique without their runner. **Allocations.** valgrind DHAT (`--tool=dhat`) reports total blocks, total bytes and the heap peak (`t-gmax`) of the whole probe. A `baseline` probe generates the same input and does nothing else; its blocks and bytes are subtracted from each probe's, which leaves exactly the measured work's allocations (a `realloc` counts as one). The peak can't be subtracted, because the baseline's peak happens during input generation, so peak tables show the whole-process peak ÷ input with the baseline beside it. **Wall-clock.** `bench time` runs one child process per library and input, one after another: one validating run, one warm-up, then at least 5 and at most 1,000 timed runs, stopping once 300 ms have been spent. Tables show the median. **Linear scaling.** Every parser runs each stress shape at 1×, 10× and 100× of 10 KB (about 1 MB at 100×), measured in instructions. A cell is `(Ir_k / bytes_k) / (Ir_1 / bytes_1)`; 1.00 is perfectly linear, and a ratio above **1.25** would be flagged **SUPERLINEAR**. **Failures are cells.** Every measurement runs in a child process, so a library that errors, crashes or hangs becomes a `rejects`, `crash` or `timeout` cell, not a failed run. Every corpus input is valid YAML 1.2, and a test checks that oneyaml accepts it. **Isolation.** The competitors are reached only through the unpublished `oracles` crate; no published oneyaml crate depends on any of them (`cargo tree` shows none of them in any published crate's graph). ## Corpus `crates/bench/src/corpus.rs` generates every input from templates written for this project. There is no third-party text, so the corpus is license-clean and versioned with the code; the FNV-1a column shows when a generator changes. The real-world shapes follow the public structure of each format; they aren't copies of real files: - **Kubernetes** (k8s-manifest, and k8s-multi with over a thousand documents): Deployment, Service, ConfigMap and Ingress, as Helm renders them. - **GitHub Actions** (gha-workflow): `on`, `env`, matrix jobs, `uses`/`with`, and `run: |`/`>-`. - **docker-compose** (compose-merge): `x-` extension fields with `&anchors`, and `<<: *merge` keys. - **OpenAPI 3.1** (openapi-100k, openapi-10m): `paths` with parameters, responses and `$ref`s, plus `components/schemas`. - **Helm values** (helm-values): an umbrella chart's `values.yaml` documented the helm-docs way (`# --` lines), with commented-out example blocks; about 70% of its bytes are comment lines. The synthetic stress shapes (`deep-block`, `deep-flow`, `wide-map`, `long-scalars`, `block-scalars`, `flow-heavy`, `comment-heavy`, `anchors`, `whitespace`) appear at 10× (about 100 KB) in the deterministic tables. `size_of::()` = 48 bytes. | Case | Kind | Scale | Bytes | FNV-1a 64 | oneyaml events (with trivia) | saphyr events (YAML only) | Contents | |---|---|---|---|---|---|---|---| | k8s-manifest | real-world | 1× | 5,633 | `055defb9d646c354` | 1,721 | 752 | Deployment + Service + ConfigMap + Ingress, one set | | gha-workflow | real-world | 1× | 3,078 | `02c8bd00cd7377f3` | 666 | 267 | GitHub Actions workflow: matrix, steps, literal-block `run` scripts | | compose-merge | real-world | 1× | 2,442 | `3099f74496483a97` | 812 | 333 | docker-compose with `x-` anchors and `<<: *` merge keys | | k8s-multi | real-world | 1× | 1,050,323 | `d4e1c07e13a3d3b2` | 316,871 | 138,252 | ~300 `---`-separated Kubernetes documents | | openapi-100k | real-world | 1× | 102,778 | `1d0382cd74933d58` | 30,974 | 13,112 | OpenAPI-style spec: paths, schemas, `$ref`s | | openapi-10m | real-world | 1× | 10,486,088 | `f1ada321f5a8840e` | 3,114,534 | 1,318,016 | the same OpenAPI shape at ~10 MB | | helm-values | real-world | 1× | 102,418 | `dc0a6f4a2039c07b` | 14,268 | 3,400 | Helm umbrella-chart values: long `# --` comment runs, commented-out blocks, comments under `{}`/`[]` | | deep-block | stress | 10× | 103,739 | `7a1e7da75647cb05` | 2,718 | 1,361 | one chain of nested block mappings (depth = sqrt(2 × size)) | | deep-flow | stress | 10× | 102,402 | `161a42f706068b17` | 102,406 | rejects | `[[[...]]]` nested flow sequences (depth = size/2) | | wide-map | stress | 10× | 102,414 | `0320a218d7370faa` | 26,781 | 10,716 | one flat block mapping with many keys | | long-scalars | stress | 10× | 102,444 | `550a8ced7e575398` | 26 | 14 | four huge scalars: plain, single, double (escapes), multi-line | | block-scalars | stress | 10× | 102,508 | `5ccc9aacc9ce1375` | 1,620 | 544 | many literal and folded scalars, every chomping indicator | | flow-heavy | stress | 10× | 102,495 | `182d89629cba5cca` | 72,681 | 31,371 | block sequence of nested flow mappings/sequences | | comment-heavy | stress | 10× | 102,400 | `89d4337fd94d69d9` | 19,044 | 4,014 | comment blocks and trailing comments on every line | | anchors | stress | 10× | 102,474 | `fdc35eda9c97bb49` | 54,556 | 21,826 | many anchors, aliases and `<<` merges | | whitespace | stress | 10× | 102,456 | `6bbeaf871cb95ae5` | 41,242 | 7,936 | blank lines, trailing spaces, wide indentation | ## Parser ### What each parser does Every column consumes its parser's **complete** output for the whole input; no parser has an event cap, and none renders anything. | Column | Call | What it produces | |---|---|---| | **oneyaml** | `oneyaml_syntax::events_with(input, &settings)` iterated to `StreamEnd`, each event counted and dropped | **Lossless events with trivia.** Every YAML event, plus trivia events that tile every input byte (whitespace, line breaks, comments, and indicators such as `:`, `-`, `[`, `,`, `---`), with spans and in-band diagnostics. Scalar values are decoded from the source on demand, not during the parse. Streamed. | | saphyr | `saphyr_parser::Parser::new_from_str` iterated to `StreamEnd` | **YAML events only, no trivia and no comments.** Streamed. | | yaml-rust2 | `yaml_rust2::parser::Parser::next_token` to `StreamEnd` | **YAML events only, no trivia and no comments.** Owned scalar strings, streamed. | | libyaml | `unsafe_libyaml::yaml_parser_parse` to `STREAM-END`, with `yaml_event_delete` after each event | **YAML events only, no trivia and no comments.** C-heap scalar copies, streamed. | | yaml-edit | `YamlFile::parse`, then a full preorder walk of the rowan CST (every node and token) | **Lossless CST**: the like-for-like comparator. | **They do different work.** oneyaml and yaml-edit are lossless: they keep every byte, including comments and whitespace. saphyr, yaml-rust2 and libyaml throw that away, so on the same input oneyaml's stream has 2–5× more events (see the corpus table's event counts). **Settings.** oneyaml runs under `Settings::new().limits(Limits::unlimited())`: the default `max_depth` of 1,024 would reject `deep-flow` at every scale and `deep-block` at 100×. The limit checks still run; they just never trip. The competitors run with their defaults. saphyr and yaml-rust2 hold the flow level in a `u8`, so they reject flow nesting deeper than 255; yaml-edit rejects `deep-block` from 10× and `deep-flow` at every scale; libyaml rejects `whitespace`, which has a tab after `- ` (valid in YAML 1.2). ### Instructions Lower is better. The ratio columns compare oneyaml with the fastest of the three event parsers, and with yaml-edit. | Case | Bytes | oneyaml Ir | oneyaml Ir/B | saphyr Ir/B | yaml-rust2 Ir/B | libyaml Ir/B | yaml-edit Ir/B | oneyaml ÷ fastest event parser | oneyaml ÷ yaml-edit | |---|---|---|---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 756,527 | 134.3 | 265.1 | 215.0 | 243.9 | 842.5 | 0.62× | 0.16× | | gha-workflow | 3,078 | 304,121 | 98.8 | 201.3 | 163.6 | 215.6 | 664.6 | 0.60× | 0.15× | | compose-merge | 2,442 | 372,294 | 152.5 | 265.8 | 225.3 | 244.3 | 856.5 | 0.68× | 0.18× | | k8s-multi | 1,050,323 | 138,676,026 | 132.0 | 256.2 | 212.8 | 243.2 | 777.5 | 0.62× | 0.17× | | openapi-100k | 102,778 | 14,226,043 | 138.4 | 259.1 | 216.7 | 244.3 | 827.1 | 0.64× | 0.17× | | openapi-10m | 10,486,088 | 1,431,450,575 | 136.5 | 243.0 | 214.2 | 241.2 | 811.3 | 0.64× | 0.17× | | helm-values | 102,418 | 4,894,822 | 47.8 | 78.5 | 77.7 | 137.3 | 304.2 | 0.62× | 0.16× | | deep-block | 103,739 | 2,017,955 | 19.5 | 47.5 | 48.4 | 101.1 | rejects | 0.41× | – | | deep-flow | 102,402 | 67,603,105 | 660.2 | rejects | rejects | 26952.1 | rejects | 0.024× | – | | wide-map | 102,414 | 14,197,231 | 138.6 | 286.3 | 226.6 | 251.7 | 821.3 | 0.61× | 0.17× | | long-scalars | 102,444 | 2,265,015 | 22.1 | 63.2 | 71.6 | 144.5 | 209.6 | 0.35× | 0.11× | | block-scalars | 102,508 | 1,407,541 | 13.7 | 51.9 | 56.6 | 133.7 | 340.0 | 0.26× | 0.040× | | flow-heavy | 102,495 | 37,277,421 | 363.7 | 586.0 | 479.5 | 449.0 | 1669.5 | 0.81× | 0.22× | | comment-heavy | 102,400 | 6,003,000 | 58.6 | 84.4 | 87.2 | 138.3 | 361.6 | 0.69× | 0.16× | | anchors | 102,474 | 26,074,600 | 254.5 | 454.4 | 365.1 | 312.9 | 1338.9 | 0.81× | 0.19× | | whitespace | 102,456 | 12,859,828 | 125.5 | 158.0 | 150.5 | rejects | 752.1 | 0.83× | 0.17× | ### Allocations **Allocation count (blocks, `realloc` included; baseline subtracted)** | Case | Bytes | oneyaml | saphyr | yaml-rust2 | libyaml | yaml-edit | oneyaml ÷ fewest event parser | |---|---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 15 | 885 | 1,710 | 1,869 | 5,358 | 0.017× | | gha-workflow | 3,078 | 14 | 396 | 666 | 726 | 2,202 | 0.035× | | compose-merge | 2,442 | 15 | 392 | 668 | 817 | 2,594 | 0.038× | | k8s-multi | 1,050,323 | 15 | 161,753 | 314,378 | 343,014 | 921,272 | 0.000093× | | openapi-100k | 102,778 | 16 | 16,405 | 30,947 | 33,356 | 94,374 | 0.00098× | | openapi-10m | 10,486,088 | 16 | 1,652,331 | 3,114,921 | 3,363,735 | 9,452,449 | 0.0000097× | | helm-values | 102,418 | 11 | 3,728 | 7,406 | 8,249 | 36,470 | 0.0030× | | deep-block | 103,739 | 14 | 932 | 1,836 | 1,846 | rejects | 0.015× | | deep-flow | 102,402 | 53 | rejects | rejects | 61 | rejects | 0.87× | | wide-map | 102,414 | 8 | 21,425 | 42,844 | 42,854 | 101,861 | 0.00037× | | long-scalars | 102,444 | 6 | 62 | 76 | 90 | 19,056 | 0.097× | | block-scalars | 102,508 | 8 | 3,501 | 3,836 | 3,242 | 30,864 | 0.0025× | | flow-heavy | 102,495 | 16 | 34,438 | 66,565 | 70,397 | 225,099 | 0.00046× | | comment-heavy | 102,400 | 8 | 5,350 | 10,692 | 10,702 | 47,901 | 0.0015× | | anchors | 102,474 | 13 | 27,618 | 46,928 | 46,927 | 187,786 | 0.00047× | | whitespace | 102,456 | 8 | 9,523 | 19,036 | rejects | 106,334 | 0.00084× | **Bytes allocated ÷ input bytes (baseline subtracted)** | Case | Bytes | oneyaml | saphyr | yaml-rust2 | libyaml | yaml-edit | oneyaml ÷ fewest event parser | |---|---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 2.56 | 12.73 | 10.11 | 20.84 | 93.88 | 0.25× | | gha-workflow | 3,078 | 4.59 | 10.87 | 7.39 | 28.57 | 67.95 | 0.62× | | compose-merge | 2,442 | 5.84 | 10.41 | 8.22 | 36.32 | 102.15 | 0.71× | | k8s-multi | 1,050,323 | 0.01 | 11.84 | 9.53 | 8.61 | 96.43 | 0.0016× | | openapi-100k | 102,778 | 0.16 | 11.70 | 9.34 | 8.68 | 86.99 | 0.018× | | openapi-10m | 10,486,088 | 0.00 | 11.51 | 9.21 | 7.95 | 91.49 | 0.00019× | | helm-values | 102,418 | 0.13 | 2.92 | 2.32 | 2.68 | 38.16 | 0.054× | | deep-block | 103,739 | 0.22 | 2.20 | 1.51 | 2.16 | rejects | 0.14× | | deep-flow | 102,402 | 107.51 | rejects | rejects | 90.87 | rejects | 1.18× | | wide-map | 102,414 | 0.12 | 16.84 | 13.39 | 10.71 | 97.67 | 0.011× | | long-scalars | 102,444 | 0.03 | 2.58 | 2.57 | 3.24 | 31.10 | 0.012× | | block-scalars | 102,508 | 0.12 | 3.42 | 2.84 | 3.82 | 29.08 | 0.042× | | flow-heavy | 102,495 | 0.24 | 25.68 | 20.44 | 17.33 | 230.15 | 0.014× | | comment-heavy | 102,400 | 0.12 | 4.21 | 3.35 | 3.18 | 54.05 | 0.038× | | anchors | 102,474 | 0.14 | 17.29 | 15.51 | 11.66 | 181.56 | 0.012× | | whitespace | 102,456 | 0.12 | 7.48 | 5.95 | rejects | 111.40 | 0.020× | **Whole-process peak heap ÷ input bytes (the baseline column is input generation alone)** | Case | Bytes | baseline | oneyaml | saphyr | yaml-rust2 | libyaml | yaml-edit | oneyaml ÷ yaml-edit | |---|---|---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 1.94 | 3.12 | 2.25 | 2.09 | 14.02 | 27.09 | 0.12× | | gha-workflow | 3,078 | 3.49 | 3.67 | 3.49 | 3.49 | 23.67 | 23.03 | 0.16× | | compose-merge | 2,442 | 4.59 | 5.30 | 4.58 | 4.59 | 30.46 | 30.65 | 0.17× | | k8s-multi | 1,050,323 | 1.00 | 1.01 | 1.01 | 1.00 | 1.07 | 29.98 | 0.034× | | openapi-100k | 102,778 | 1.71 | 1.71 | 1.71 | 1.71 | 2.01 | 24.54 | 0.070× | | openapi-10m | 10,486,088 | 1.67 | 1.67 | 1.67 | 1.67 | 1.67 | 27.13 | 0.061× | | helm-values | 102,418 | 3.03 | 3.03 | 3.03 | 3.03 | 3.03 | 14.12 | 0.21× | | deep-block | 103,739 | 1.04 | 1.14 | 1.78 | 1.51 | 2.23 | rejects | – | | deep-flow | 102,402 | 1.02 | 54.77 | rejects | rejects | 46.78 | rejects | – | | wide-map | 102,414 | 1.06 | 1.11 | 1.06 | 1.06 | 1.72 | 28.94 | 0.038× | | long-scalars | 102,444 | 1.30 | 1.30 | 1.34 | 1.33 | 2.00 | 13.22 | 0.098× | | block-scalars | 102,508 | 1.06 | 1.11 | 1.06 | 1.06 | 1.72 | 11.92 | 0.093× | | flow-heavy | 102,495 | 1.06 | 1.17 | 1.21 | 1.16 | 1.81 | 66.97 | 0.017× | | comment-heavy | 102,400 | 1.06 | 1.11 | 1.06 | 1.06 | 1.72 | 18.29 | 0.061× | | anchors | 102,474 | 1.06 | 1.12 | 2.48 | 3.17 | 1.72 | 50.22 | 0.022× | | whitespace | 102,456 | 1.06 | 1.11 | 1.06 | 1.06 | rejects | 35.72 | 0.031× | ### Collecting (`parse_with`) What keeping the whole stream costs: `oneyaml-collect` runs `parse_with` (an `EventList`, 48 bytes per event) and walks it, against the pull parser dropping each event. DHAT peak heap and bytes allocated, net of the input-generation baseline, divided by the input size. | Case | Bytes | pulled: peak ÷ input | collected: peak ÷ input | collected: bytes allocated ÷ input | |---|---|---|---|---| | k8s-manifest | 5,633 | 1.18 | 18.63 | 37.43 | | gha-workflow | 3,078 | 0.18 | 16.15 | 36.47 | | compose-merge | 2,442 | 0.72 | 20.85 | 46.02 | | k8s-multi | 1,050,323 | 0.01 | 23.97 | 47.93 | | openapi-100k | 102,778 | 0.00 | 15.02 | 30.76 | | openapi-10m | 10,486,088 | 0.00 | 18.87 | 38.40 | | helm-values | 102,418 | 0.00 | 6.72 | 15.48 | | deep-block | 103,739 | 0.10 | 2.00 | 4.01 | | deep-flow | 102,402 | 53.75 | 115.19 | 230.39 | | wide-map | 102,414 | 0.05 | 15.41 | 30.83 | | long-scalars | 102,444 | 0.00 | 0.00 | 0.06 | | block-scalars | 102,508 | 0.05 | 1.01 | 2.04 | | flow-heavy | 102,495 | 0.12 | 61.50 | 123.01 | | comment-heavy | 102,400 | 0.05 | 15.41 | 30.84 | | anchors | 102,474 | 0.06 | 30.76 | 61.53 | | whitespace | 102,456 | 0.05 | 30.76 | 61.53 | ### Syntax tree (`SyntaxTree::parse_with`) What the lossless syntax tree costs. `oneyaml-tree` builds the tree from the pull parser (the tree owns a copy of the input, as a `Document` does), walks every element, and drops it; `oneyaml-tree-retained` keeps the built tree, so DHAT's end-of-run figure is exactly what a tree retains. Both are divided by the input size and **include the tree's own copy of the input (1.00×)**. "peak" is the whole-process peak minus the heap the baseline still holds at its end (the generated input). The arena stores 9 bytes per element, so retained ≈ 1 + 9 × elements ÷ byte; the densest synthetic shapes, where nearly every byte is its own element, retain the most. "stream Ir/B" is the pull parser drained; "tree Ir/B" is parse + build + walk + drop. | Case | Bytes | tree elements | elements ÷ byte | peak ÷ input | retained ÷ input | stream Ir/B | tree Ir/B | tree ÷ stream | |---|---|---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 2,053 | 0.36 | 10.64 | 4.31 | 134.3 | 200.2 | 1.49× | | gha-workflow | 3,078 | 793 | 0.26 | 7.84 | 3.48 | 98.8 | 147.0 | 1.49× | | compose-merge | 2,442 | 997 | 0.41 | 9.92 | 4.67 | 152.5 | 223.6 | 1.47× | | k8s-multi | 1,050,323 | 378,253 | 0.36 | 6.56 | 4.27 | 132.0 | 190.6 | 1.44× | | openapi-100k | 102,778 | 38,392 | 0.37 | 8.16 | 4.36 | 138.4 | 201.1 | 1.45× | | openapi-10m | 10,486,088 | 3,860,896 | 0.37 | 5.93 | 4.31 | 136.5 | 194.9 | 1.43× | | helm-values | 102,418 | 16,215 | 0.16 | 4.50 | 2.42 | 47.8 | 75.6 | 1.58× | | deep-block | 103,739 | 2,717 | 0.03 | 2.51 | 1.24 | 19.5 | 25.4 | 1.31× | | deep-flow | 102,402 | 153,605 | 1.50 | 81.36 | 14.50 | 660.2 | 890.3 | 1.35× | | wide-map | 102,414 | 37,488 | 0.37 | 7.86 | 4.29 | 138.6 | 197.8 | 1.43× | | long-scalars | 102,444 | 31 | 0.00 | 2.02 | 1.00 | 22.1 | 23.2 | 1.05× | | block-scalars | 102,508 | 2,155 | 0.02 | 2.74 | 1.34 | 13.7 | 19.2 | 1.40× | | flow-heavy | 102,495 | 97,158 | 0.95 | 13.68 | 9.53 | 363.7 | 512.3 | 1.41× | | comment-heavy | 102,400 | 21,045 | 0.21 | 4.98 | 2.85 | 58.6 | 95.0 | 1.62× | | anchors | 102,474 | 70,918 | 0.69 | 13.63 | 7.23 | 254.5 | 368.2 | 1.45× | | whitespace | 102,456 | 44,411 | 0.43 | 7.86 | 4.90 | 125.5 | 201.7 | 1.61× | ### Linear scaling | Shape | Bytes 1× / 10× / 100× | oneyaml Ir at 100× | oneyaml (10× / 100×) | saphyr (10× / 100×) | yaml-rust2 (10× / 100×) | libyaml (10× / 100×) | yaml-edit (10× / 100×) | |---|---|---|---|---|---|---|---| | deep-block | 10,730 / 103,739 / 1,028,894 | 14,075,486 | 0.52 / 0.37 | 0.55 / 0.41 | 0.63 / 0.51 | 0.84 / 0.78 | rejects at 10× | | deep-flow | 10,242 / 102,402 / 1,024,002 | 671,315,759 | 0.87 / 0.86 | rejects at 1× | rejects at 1× | 1.09 / 1.10 | rejects at 1× | | wide-map | 10,249 / 102,414 / 1,024,009 | 141,913,640 | 1.00 / 1.00 | 1.00 / 1.00 | 1.00 / 1.00 | 1.00 / 1.00 | 0.98 / 0.97 | | long-scalars | 10,301 / 102,444 / 1,024,061 | 22,525,089 | 0.95 / 0.95 | 0.97 / 0.96 | 0.97 / 0.97 | 0.99 / 0.99 | 0.98 / 0.97 | | block-scalars | 10,329 / 102,508 / 1,024,352 | 13,671,453 | 0.96 / 0.93 | 0.99 / 0.97 | 0.98 / 0.96 | 0.99 / 0.99 | 0.97 / 0.95 | | flow-heavy | 10,240 / 102,495 / 1,024,082 | 369,711,943 | 0.99 / 0.98 | 0.99 / 0.99 | 0.99 / 0.99 | 0.99 / 0.99 | 0.98 / 0.97 | | comment-heavy | 10,356 / 102,400 / 1,024,179 | 59,614,679 | 0.99 / 0.98 | 0.99 / 0.99 | 0.99 / 0.99 | 0.99 / 1.00 | 0.97 / 0.96 | | anchors | 10,268 / 102,474 / 1,024,001 | 236,871,723 | 0.90 / 0.82 | 0.95 / 0.92 | 0.90 / 0.83 | 0.93 / 0.88 | 0.91 / 0.82 | | whitespace | 10,276 / 102,456 / 1,024,028 | 125,509,238 | 0.97 / 0.95 | 0.98 / 0.96 | 0.98 / 0.96 | rejects at 1× | 0.97 / 0.94 | ### Wall-clock (informational only) Real-world inputs run at 1× and stress shapes at 100× (about 1 MB). Higher MB/s is better. The ratio columns are time ratios, so lower is better for oneyaml. Host: AMD Ryzen 9 7950X3D 16-Core Processor; load average (1/5/15 min) at start: 5.72 / 7.78 / 8.81. MB/s = 10⁶ bytes/s, median of ≥ 5 runs. | Case | Bytes | oneyaml MB/s | saphyr MB/s | yaml-rust2 MB/s | libyaml MB/s | yaml-edit MB/s | oneyaml time ÷ fastest event parser | oneyaml time ÷ yaml-edit | |---|---|---|---|---|---|---|---|---| | k8s-manifest@1× | 5,633 | 124 | 56 | 66 | 85 | 22 | 0.69× | 0.18× | | gha-workflow@1× | 3,078 | 164 | 75 | 90 | 98 | 30 | 0.59× | 0.18× | | compose-merge@1× | 2,442 | 105 | 54 | 62 | 95 | 23 | 0.90× | 0.22× | | k8s-multi@1× | 1,050,323 | 126 | 55 | 67 | 90 | 20 | 0.71× | 0.16× | | openapi-100k@1× | 102,778 | 122 | 54 | 65 | 95 | 22 | 0.77× | 0.18× | | openapi-10m@1× | 10,486,088 | 121 | 55 | 64 | 95 | 19 | 0.79× | 0.16× | | helm-values@1× | 102,418 | 353 | 198 | 215 | 194 | 60 | 0.61× | 0.17× | | deep-block@100× | 1,028,894 | 1709 | 622 | 649 | 327 | rejects | 0.38× | – | | deep-flow@100× | 1,024,002 | 20 | rejects | rejects | 1 | timeout (> 75 s) | 0.045× | – | | wide-map@100× | 1,024,009 | 131 | 55 | 68 | 86 | 19 | 0.66× | 0.15× | | long-scalars@100× | 1,024,061 | 1334 | 473 | 364 | 175 | 109 | 0.35× | 0.081× | | block-scalars@100× | 1,024,352 | 1557 | 441 | 421 | 211 | 60 | 0.28× | 0.038× | | flow-heavy@100× | 1,024,082 | 49 | 24 | 30 | 48 | 10 | 0.99× | 0.20× | | comment-heavy@100× | 1,024,179 | 290 | 161 | 178 | 192 | 47 | 0.66× | 0.16× | | anchors@100× | 1,024,001 | 71 | 21 | 37 | 77 | 10 | 1.08× | 0.15× | | whitespace@100× | 1,024,028 | 144 | 85 | 107 | rejects | 26 | 0.74× | 0.18× | Each stress shape's throughput at 1×, 10× and 100× (10 KB, 100 KB and 1 MB): Host: AMD Ryzen 9 7950X3D 16-Core Processor; load average (1/5/15 min) at start: 5.60 / 7.09 / 8.44. MB/s = 10⁶ bytes/s, median of ≥ 5 runs. | Shape | oneyaml MB/s 1× / 10× / 100× | saphyr MB/s 1× / 10× / 100× | yaml-rust2 MB/s 1× / 10× / 100× | libyaml MB/s 1× / 10× / 100× | yaml-edit MB/s 1× / 10× / 100× | |---|---|---|---|---|---| | deep-block | 463 / 985 / 1712 | 175 / 379 / 598 | 199 / 410 / 637 | 213 / 303 / 335 | 16 / rejects at 10× | | deep-flow | 12 / 16 / 20 | rejects at 1× | rejects at 1× | 1 / 1 / 1 | rejects at 1× | | wide-map | 127 / 132 / 129 | 55 / 55 / 53 | 69 / 68 / 69 | 85 / 85 / 82 | 26 / 23 / 16 | | long-scalars | 1128 / 1285 / 1276 | 407 / 454 / 449 | 323 / 356 / 363 | 167 / 173 / 170 | 110 / 93 / 112 | | block-scalars | 882 / 852 / 985 | 388 / 418 / 442 | 359 / 413 / 419 | 207 / 207 / 206 | 64 / 67 / 64 | | flow-heavy | 46 / 48 / 47 | 24 / 25 / 24 | 29 / 28 / 30 | 49 / 50 / 48 | 11 / 11 / 11 | | comment-heavy | 287 / 300 / 294 | 158 / 160 / 155 | 181 / 183 / 184 | 193 / 195 / 185 | 58 / 53 / 47 | | anchors | 61 / 66 / 70 | 26 / 28 / 29 | 34 / 36 / 39 | 68 / 59 / 78 | 12 / 13 / 11 | | whitespace | 136 / 142 / 140 | 83 / 82 / 86 | 99 / 100 / 107 | rejects at 1× | 26 / 23 / 25 | ## Serde read **Comparators**, pinned in `crates/oracles/Cargo.toml`: | Library | Version | Documents | Merge keys (`<<`) | Dynamic value type | |---|---|---|---|---| | **oneyaml** | this tree | `Deserializer::from_str_with` iterator | `Settings::merge_keys(true)`, on compose-merge only (off by default) | `oneyaml::Value` | | serde_yaml_ng | 0.10.0 | `Deserializer::from_str` iterator | not in its deserializer; only `Value::apply_merge`, so typed reads go `Value` → `apply_merge` → `from_value` (the extra work it needs to be equivalent) | `serde_yaml_ng::Value` | | serde_norway | 0.9.42 | as serde_yaml_ng | as serde_yaml_ng | `serde_norway::Value` | | serde_yaml2 | 0.1.3 | **none**: reads the first document only | **none** | `yaml_rust2::Yaml` | | serde-saphyr | 1.3.0 | `from_multiple` | expanded by default | none of its own: `serde_json::Value` | | serde_json | 1.0.151 | the JSON is an array of the documents | already expanded in the JSON | `serde_json::Value` | serde-saphyr's default resource budget rejects k8s-multi (more than 1,024 documents) and openapi-10m (more than 250,000 nodes and 1,000,000 events), inputs oneyaml's `Limits::default()` accepts, so it runs with those three caps lifted and every other budget check on. The saphyr project's own `saphyr-serde` crate is still a placeholder; serde-saphyr is a third-party crate built on a fork of saphyr-parser. **Workloads**, each on every real-world corpus input at 1×: - `value`: every document into the library's dynamic value type; - `typed`: every document into realistic derived structs (`crates/bench/src/models.rs`: Kubernetes objects as a `kind`-tagged enum with an untagged `IntOrString`; a GitHub Actions workflow; a compose file whose services get `restart`/`logging`/`networks` from `<<: *common`; an OpenAPI document with nested schemas; Helm values read through `#[serde(flatten)]`); - `sparse`: every document into a struct that reads a few fields and skips the rest. oneyaml runs with `Settings::new()` (so `Limits::default()`, unlike the parser tables), plus `merge_keys` on compose-merge. **serde_json** reads the equivalent JSON (oneyaml's `Value` of each document, merges expanded, serialized compactly and cached so the probe reads a file). It is a reference ceiling, not a YAML comparator: JSON has no plain scalars to resolve, no indentation and no anchors. Its Ir/B and MB/s are per byte of the YAML input. **Metrics**: callgrind instructions inside the measured function (deserialize every document, then drop the result); DHAT totals minus a baseline probe that only prepares the input; the whole-process DHAT peak ÷ input beside the baseline's; and wall-clock medians. ### Correctness guard Before anything is measured, `bench serde-check ` runs oneyaml and the competitor, converts every document of both results to `serde_json::Value`, and compares them. A competitor that differs, rejects or can't do the equivalent work is not measured on that row; the reason is listed under the table. Two notes on the reasons: serde_yaml2 reads the *quoted* scalar `"1.85"` as a float, where YAML 1.2 (and 1.1) make it a string; and serde_json rejects gha-workflow `typed` because in the JSON equivalent `RUST_BACKTRACE: 1` is a number, while every YAML library reads the plain scalar `1` into a `String` from its source text. The second is a property of JSON as a reference, not a disagreement between YAML libraries. | Case | Workload | oneyaml | serde_yaml_ng | serde_norway | serde_yaml2 | serde-saphyr | serde_json | |---|---|---|---|---|---|---|---| | k8s-manifest | value | agrees | agrees | agrees | n/a [1] | agrees | agrees | | k8s-manifest | typed | agrees | agrees | agrees | n/a [2] | agrees | agrees | | k8s-manifest | sparse | agrees | agrees | agrees | n/a [3] | agrees | agrees | | gha-workflow | value | agrees | agrees | agrees | differs [4] | agrees | agrees | | gha-workflow | typed | agrees | agrees | agrees | agrees | agrees | rejects [5] | | gha-workflow | sparse | agrees | agrees | agrees | agrees | agrees | agrees | | compose-merge | value | agrees | agrees | agrees | n/a [6] | agrees | agrees | | compose-merge | typed | agrees | agrees | agrees | n/a [7] | agrees | agrees | | compose-merge | sparse | agrees | agrees | agrees | n/a [8] | agrees | agrees | | k8s-multi | value | agrees | agrees | agrees | n/a [9] | agrees | agrees | | k8s-multi | typed | agrees | agrees | agrees | n/a [10] | agrees | agrees | | k8s-multi | sparse | agrees | agrees | agrees | n/a [11] | agrees | agrees | | openapi-100k | value | agrees | agrees | agrees | agrees | agrees | agrees | | openapi-100k | typed | agrees | agrees | agrees | agrees | agrees | agrees | | openapi-100k | sparse | agrees | agrees | agrees | agrees | agrees | agrees | | openapi-10m | value | agrees | agrees | agrees | agrees | agrees | agrees | | openapi-10m | typed | agrees | agrees | agrees | agrees | agrees | agrees | | openapi-10m | sparse | agrees | agrees | agrees | agrees | agrees | agrees | | helm-values | value | agrees | agrees | agrees | agrees | agrees | agrees | | helm-values | typed | agrees | agrees | agrees | agrees | agrees | agrees | | helm-values | sparse | agrees | agrees | agrees | agrees | agrees | agrees | 1. serde_yaml2, k8s-manifest value: no multi-document API (reads the first document only) 2. serde_yaml2, k8s-manifest typed: no multi-document API (reads the first document only) 3. serde_yaml2, k8s-manifest sparse: no multi-document API (reads the first document only) 4. serde_yaml2, gha-workflow value: doc 0 /jobs/bravo-2/strategy/matrix/toolchain/2: 1.85 (ours "1.85") 5. serde_json, gha-workflow typed: invalid type: integer '1', expected a string at line 1 column 845 6. serde_yaml2, compose-merge value: no merge-key support 7. serde_yaml2, compose-merge typed: no merge-key support 8. serde_yaml2, compose-merge sparse: no merge-key support 9. serde_yaml2, k8s-multi value: no multi-document API (reads the first document only) 10. serde_yaml2, k8s-multi typed: no multi-document API (reads the first document only) 11. serde_yaml2, k8s-multi sparse: no multi-document API (reads the first document only) ### Instructions Lower is better. The ratio columns compare oneyaml with the fastest feature-complete library on that row (named in the next column; the target applies to this ratio), with serde_yaml2 (partial, no target), and with serde_json. | Case | Workload | Bytes | oneyaml Ir/B | serde_yaml_ng Ir/B | serde_norway Ir/B | serde_yaml2 Ir/B | serde-saphyr Ir/B | serde_json Ir/B | oneyaml ÷ fastest feature-complete (target applies) | fastest feature-complete | target ≤ 1.25× | oneyaml ÷ serde_yaml2 (partial, no target) | oneyaml ÷ serde_json | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | 383.8 | 457.3 | 458.5 | n/a | 729.4 | 69.9 | 0.84× | serde_yaml_ng | met | – | 5.49× | | k8s-manifest | typed | 5,633 | 344.2 | 423.5 | 423.4 | n/a | 819.4 | 65.0 | 0.81× | serde_norway | met | – | 5.30× | | k8s-manifest | sparse | 5,633 | 306.2 | 309.9 | 309.9 | n/a | 723.0 | 16.1 | 0.99× | serde_yaml_ng | met | – | 18.99× | | gha-workflow | value | 3,078 | 255.6 | 353.4 | 354.2 | differs | 499.1 | 46.1 | 0.72× | serde_yaml_ng | met | – | 5.55× | | gha-workflow | typed | 3,078 | 231.1 | 292.2 | 292.2 | 178.8 | 440.5 | rejects | 0.79× | serde_yaml_ng | met | 1.29× | – | | gha-workflow | sparse | 3,078 | 215.9 | 259.1 | 259.1 | 179.6 | 499.4 | 11.2 | 0.83× | serde_norway | met | 1.20× | 19.20× | | compose-merge | value | 2,442 | 591.6 | 593.8 | 596.1 | n/a | 1116.9 | 114.8 | 1.00× | serde_yaml_ng | met | – | 5.15× | | compose-merge | typed | 2,442 | 529.6 | 623.3 | 625.5 | n/a | 999.1 | 76.3 | 0.85× | serde_yaml_ng | met | – | 6.94× | | compose-merge | sparse | 2,442 | 484.3 | 600.2 | 602.1 | n/a | 1070.2 | 29.6 | 0.81× | serde_yaml_ng | met | – | 16.35× | | k8s-multi | value | 1,050,323 | 379.6 | 457.5 | 458.8 | n/a | 721.2 | 70.0 | 0.83× | serde_yaml_ng | met | – | 5.42× | | k8s-multi | typed | 1,050,323 | 340.9 | 422.1 | 422.1 | n/a | 808.9 | 66.2 | 0.81× | serde_norway | met | – | 5.15× | | k8s-multi | sparse | 1,050,323 | 299.8 | 354.0 | 354.0 | n/a | 726.8 | 16.2 | 0.85× | serde_yaml_ng | met | – | 18.52× | | openapi-100k | value | 102,778 | 381.5 | 454.2 | 454.4 | 328.8 | 729.8 | 70.7 | 0.84× | serde_yaml_ng | met | 1.16× | 5.39× | | openapi-100k | typed | 102,778 | 333.1 | 348.4 | 348.4 | 237.6 | 649.6 | 44.1 | 0.96× | serde_yaml_ng | met | 1.40× | 7.55× | | openapi-100k | sparse | 102,778 | 304.9 | 297.3 | 297.3 | 240.9 | 752.4 | 12.9 | 1.03× | serde_norway | met | 1.27× | 23.60× | | openapi-10m | value | 10,486,088 | 375.5 | 447.7 | 449.0 | 324.4 | 720.3 | 70.7 | 0.84× | serde_yaml_ng | met | 1.16× | 5.31× | | openapi-10m | typed | 10,486,088 | 329.6 | 346.4 | 346.4 | 236.3 | 641.6 | 44.3 | 0.95× | serde_yaml_ng | met | 1.39× | 7.44× | | openapi-10m | sparse | 10,486,088 | 300.4 | 294.7 | 294.7 | 238.1 | 741.5 | 12.8 | 1.02× | serde_norway | met | 1.26× | 23.55× | | helm-values | value | 102,418 | 110.4 | 186.8 | 187.2 | 103.3 | 236.8 | 16.6 | 0.59× | serde_yaml_ng | met | 1.07× | 6.64× | | helm-values | typed | 102,418 | 102.1 | 174.8 | 174.7 | 94.7 | 263.3 | 11.9 | 0.58× | serde_norway | met | 1.08× | 8.56× | | helm-values | sparse | 102,418 | 94.8 | 151.3 | 151.3 | 83.2 | 245.2 | 3.1 | 0.63× | serde_norway | met | 1.14× | 30.16× | ### Allocations **Allocation count (blocks, `realloc` included; baseline subtracted)** | Case | Workload | Bytes | oneyaml | serde_yaml_ng | serde_norway | serde_yaml2 | serde-saphyr | serde_json | oneyaml ÷ best feature-complete | best feature-complete | oneyaml ÷ serde_yaml2 (partial) | |---|---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | 1,038 | 3,099 | 3,099 | n/a | 1,422 | 567 | 0.73× | serde-saphyr | – | | k8s-manifest | typed | 5,633 | 620 | 2,805 | 2,805 | n/a | 2,313 | 459 | 0.27× | serde-saphyr | – | | k8s-manifest | sparse | 5,633 | 105 | 2,415 | 2,415 | n/a | 1,716 | 28 | 0.061× | serde-saphyr | – | | gha-workflow | value | 3,078 | 365 | 1,155 | 1,155 | differs | 597 | 215 | 0.61× | serde-saphyr | – | | gha-workflow | typed | 3,078 | 201 | 1,031 | 1,031 | 698 | 337 | rejects | 0.60× | serde-saphyr | 0.29× | | gha-workflow | sparse | 3,078 | 58 | 914 | 914 | 802 | 718 | 10 | 0.081× | serde-saphyr | 0.072× | | compose-merge | value | 2,442 | 764 | 1,584 | 1,584 | n/a | 1,496 | 401 | 0.51× | serde-saphyr | – | | compose-merge | typed | 2,442 | 488 | 1,639 | 1,639 | n/a | 1,172 | 245 | 0.42× | serde-saphyr | – | | compose-merge | sparse | 2,442 | 188 | 1,585 | 1,585 | n/a | 1,506 | 30 | 0.12× | serde-saphyr | – | | k8s-multi | value | 1,050,323 | 187,496 | 569,202 | 569,202 | n/a | 259,380 | 103,425 | 0.72× | serde-saphyr | – | | k8s-multi | typed | 1,050,323 | 110,628 | 515,008 | 515,008 | n/a | 423,621 | 83,517 | 0.26× | serde-saphyr | – | | k8s-multi | sparse | 1,050,323 | 10,563 | 443,118 | 443,118 | n/a | 313,574 | 4,436 | 0.034× | serde-saphyr | – | | openapi-100k | value | 102,778 | 17,151 | 54,250 | 54,250 | 48,318 | 24,687 | 9,965 | 0.69× | serde-saphyr | 0.35× | | openapi-100k | typed | 102,778 | 6,583 | 45,636 | 45,636 | 31,934 | 12,385 | 4,189 | 0.53× | serde-saphyr | 0.21× | | openapi-100k | sparse | 102,778 | 167 | 41,456 | 41,456 | 38,091 | 35,491 | 5 | 0.0047× | serde-saphyr | 0.0044× | | openapi-10m | value | 10,486,088 | 1,718,287 | 5,461,198 | 5,461,198 | 4,860,265 | 2,479,934 | 1,002,271 | 0.69× | serde-saphyr | 0.35× | | openapi-10m | typed | 10,486,088 | 659,270 | 4,597,784 | 4,597,784 | 3,214,454 | 1,243,104 | 421,511 | 0.53× | serde-saphyr | 0.21× | | openapi-10m | sparse | 10,486,088 | 9,138 | 4,176,282 | 4,176,282 | 3,834,137 | 3,569,112 | 5 | 0.0026× | serde-saphyr | 0.0024× | | helm-values | value | 102,418 | 3,466 | 12,639 | 12,639 | 11,060 | 7,282 | 1,992 | 0.48× | serde-saphyr | 0.31× | | helm-values | typed | 102,418 | 1,254 | 10,977 | 10,977 | 9,793 | 11,419 | 840 | 0.11× | serde_yaml_ng | 0.13× | | helm-values | sparse | 102,418 | 41 | 10,176 | 10,176 | 8,919 | 10,581 | 3 | 0.0040× | serde_yaml_ng | 0.0046× | **Bytes allocated ÷ YAML input bytes (baseline subtracted)** | Case | Workload | Bytes | oneyaml | serde_yaml_ng | serde_norway | serde_yaml2 | serde-saphyr | serde_json | oneyaml ÷ best feature-complete | best feature-complete | oneyaml ÷ serde_yaml2 (partial) | |---|---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | 38.84 | 78.76 | 78.76 | n/a | 48.37 | 15.37 | 0.80× | serde-saphyr | – | | k8s-manifest | typed | 5,633 | 25.62 | 72.95 | 72.95 | n/a | 49.32 | 13.10 | 0.52× | serde-saphyr | – | | k8s-manifest | sparse | 5,633 | 6.75 | 60.20 | 60.20 | n/a | 35.93 | 0.28 | 0.19× | serde-saphyr | – | | gha-workflow | value | 3,078 | 23.63 | 71.14 | 71.14 | differs | 34.07 | 9.40 | 0.69× | serde-saphyr | – | | gha-workflow | typed | 3,078 | 15.86 | 66.95 | 66.95 | 12.83 | 28.96 | rejects | 0.55× | serde-saphyr | 1.24× | | gha-workflow | sparse | 3,078 | 6.51 | 61.23 | 61.23 | 8.13 | 26.78 | 0.22 | 0.24× | serde-saphyr | 0.80× | | compose-merge | value | 2,442 | 66.94 | 110.24 | 110.24 | n/a | 163.34 | 15.95 | 0.61× | serde_norway | – | | compose-merge | typed | 2,442 | 91.36 | 116.54 | 116.54 | n/a | 153.33 | 7.49 | 0.78× | serde_norway | – | | compose-merge | sparse | 2,442 | 69.89 | 110.58 | 110.57 | n/a | 150.19 | 0.48 | 0.63× | serde_norway | – | | k8s-multi | value | 1,050,323 | 35.67 | 66.09 | 66.09 | n/a | 44.57 | 15.28 | 0.80× | serde-saphyr | – | | k8s-multi | typed | 1,050,323 | 23.14 | 60.90 | 60.90 | n/a | 46.18 | 13.70 | 0.50× | serde-saphyr | – | | k8s-multi | sparse | 1,050,323 | 2.12 | 47.66 | 47.66 | n/a | 32.30 | 0.41 | 0.066× | serde-saphyr | – | | openapi-100k | value | 102,778 | 30.29 | 56.83 | 56.83 | 21.11 | 43.34 | 14.55 | 0.70× | serde-saphyr | 1.44× | | openapi-100k | typed | 102,778 | 18.72 | 48.83 | 48.83 | 18.09 | 36.55 | 9.00 | 0.51× | serde-saphyr | 1.03× | | openapi-100k | sparse | 102,778 | 1.05 | 39.84 | 39.84 | 9.87 | 32.14 | 0.00 | 0.033× | serde-saphyr | 0.11× | | openapi-10m | value | 10,486,088 | 29.97 | 63.53 | 63.53 | 20.80 | 42.54 | 14.38 | 0.70× | serde-saphyr | 1.44× | | openapi-10m | typed | 10,486,088 | 18.28 | 55.87 | 55.87 | 17.91 | 35.89 | 8.96 | 0.51× | serde-saphyr | 1.02× | | openapi-10m | sparse | 10,486,088 | 0.72 | 46.91 | 46.91 | 9.74 | 31.48 | 0.00 | 0.023× | serde-saphyr | 0.073× | | helm-values | value | 102,418 | 7.55 | 14.48 | 14.48 | 4.91 | 14.26 | 2.53 | 0.53× | serde-saphyr | 1.54× | | helm-values | typed | 102,418 | 4.36 | 12.45 | 12.45 | 4.40 | 14.68 | 1.94 | 0.35× | serde_norway | 0.99× | | helm-values | sparse | 102,418 | 0.33 | 10.51 | 10.51 | 2.46 | 12.74 | 0.00 | 0.031× | serde_norway | 0.13× | **Whole-process peak heap ÷ YAML input bytes (the baseline column is YAML generation alone)** | Case | Workload | Bytes | baseline | oneyaml | serde_yaml_ng | serde_norway | serde_yaml2 | serde-saphyr | serde_json | oneyaml ÷ best feature-complete | best feature-complete | oneyaml ÷ serde_yaml2 (partial) | |---|---|---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | 1.94 | 26.04 | 32.37 | 32.37 | n/a | 21.58 | 16.27 | 1.21× | serde-saphyr | – | | k8s-manifest | typed | 5,633 | 1.94 | 9.77 | 23.39 | 23.39 | n/a | 12.55 | 7.16 | 0.78× | serde-saphyr | – | | k8s-manifest | sparse | 5,633 | 1.94 | 5.28 | 18.57 | 18.57 | n/a | 6.88 | 2.64 | 0.77× | serde-saphyr | – | | gha-workflow | value | 3,078 | 3.50 | 17.42 | 49.35 | 49.35 | differs | 18.07 | 10.24 | 0.96× | serde-saphyr | – | | gha-workflow | typed | 3,078 | 3.50 | 9.67 | 45.44 | 45.44 | 7.48 | 14.23 | rejects | 0.68× | serde-saphyr | 1.29× | | gha-workflow | sparse | 3,078 | 3.50 | 5.02 | 40.35 | 40.35 | 3.50 | 9.41 | 3.50 | 0.53× | serde-saphyr | 1.43× | | compose-merge | value | 2,442 | 4.59 | 36.32 | 73.41 | 73.41 | n/a | 31.43 | 17.60 | 1.16× | serde-saphyr | – | | compose-merge | typed | 2,442 | 4.59 | 19.72 | 73.41 | 73.41 | n/a | 23.58 | 9.13 | 0.84× | serde-saphyr | – | | compose-merge | sparse | 2,442 | 4.59 | 13.66 | 73.42 | 73.41 | n/a | 17.42 | 4.59 | 0.78× | serde-saphyr | – | | k8s-multi | value | 1,050,323 | 1.00 | 22.94 | 17.21 | 17.21 | n/a | 16.18 | 16.01 | 1.42× | serde-saphyr | – | | k8s-multi | typed | 1,050,323 | 1.00 | 7.02 | 7.08 | 7.08 | n/a | 7.02 | 6.85 | 1.00× | serde-saphyr | – | | k8s-multi | sparse | 1,050,323 | 1.00 | 1.25 | 1.32 | 1.32 | n/a | 1.26 | 1.86 | 0.99× | serde-saphyr | – | | openapi-100k | value | 102,778 | 1.71 | 22.73 | 32.71 | 32.71 | 12.91 | 16.21 | 15.39 | 1.40× | serde-saphyr | 1.76× | | openapi-100k | typed | 102,778 | 1.71 | 10.57 | 26.86 | 26.86 | 11.04 | 10.65 | 9.83 | 0.99× | serde-saphyr | 0.96× | | openapi-100k | sparse | 102,778 | 1.71 | 1.96 | 17.87 | 17.87 | 1.71 | 1.78 | 2.17 | 1.10× | serde-saphyr | 1.15× | | openapi-10m | value | 10,486,088 | 1.67 | 22.52 | 35.67 | 35.67 | 12.73 | 15.82 | 15.21 | 1.42× | serde-saphyr | 1.77× | | openapi-10m | typed | 10,486,088 | 1.67 | 10.45 | 30.06 | 30.06 | 10.98 | 10.40 | 9.79 | 1.00× | serde-saphyr | 0.95× | | openapi-10m | sparse | 10,486,088 | 1.67 | 1.75 | 21.10 | 21.10 | 1.67 | 1.67 | 2.16 | 1.05× | serde-saphyr | 1.05× | | helm-values | value | 102,418 | 3.03 | 6.22 | 9.61 | 9.61 | 4.51 | 4.78 | 3.03 | 1.30× | serde-saphyr | 1.38× | | helm-values | typed | 102,418 | 3.03 | 3.68 | 8.20 | 8.20 | 3.69 | 3.91 | 3.03 | 0.94× | serde-saphyr | 1.00× | | helm-values | sparse | 102,418 | 3.03 | 3.03 | 6.67 | 6.67 | 3.03 | 3.03 | 3.03 | 1.00× | serde-saphyr | 1.00× | ### Wall-clock (informational only) Higher MB/s is better (per byte of YAML input, also for serde_json); the ratio columns are time ratios, lower is better for oneyaml. Host: AMD Ryzen 9 7950X3D 16-Core Processor; load average (1/5/15 min) at start: 7.57 / 7.76 / 8.36. MB/s = 10⁶ bytes/s, median of ≥ 5 runs. | Case | Workload | Bytes | oneyaml MB/s | serde_yaml_ng MB/s | serde_norway MB/s | serde_yaml2 MB/s | serde-saphyr MB/s | serde_json MB/s | oneyaml ÷ fastest feature-complete | fastest feature-complete | oneyaml ÷ serde_yaml2 (partial) | oneyaml ÷ serde_json | |---|---|---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | 37 | 36 | 36 | n/a | 17 | 256 | 0.96× | serde_yaml_ng | – | 6.85× | | k8s-manifest | typed | 5,633 | 42 | 38 | 39 | n/a | 15 | 229 | 0.92× | serde_norway | – | 5.48× | | k8s-manifest | sparse | 5,633 | 50 | 54 | 58 | n/a | 18 | 1486 | 1.17× | serde_norway | – | 29.86× | | gha-workflow | value | 3,078 | 58 | 48 | 51 | differs | 26 | 370 | 0.88× | serde_norway | – | 6.36× | | gha-workflow | typed | 3,078 | 64 | 64 | 64 | 78 | 29 | rejects | 0.99× | serde_yaml_ng | 1.21× | – | | gha-workflow | sparse | 3,078 | 69 | 71 | 72 | 86 | 26 | 2123 | 1.04× | serde_norway | 1.25× | 30.69× | | compose-merge | value | 2,442 | 25 | 29 | 28 | n/a | 12 | 140 | 1.14× | serde_yaml_ng | – | 5.52× | | compose-merge | typed | 2,442 | 27 | 27 | 26 | n/a | 8 | 141 | 1.00× | serde_yaml_ng | – | 5.25× | | compose-merge | sparse | 2,442 | 19 | 28 | 27 | n/a | 13 | 731 | 1.50× | serde_yaml_ng | – | 39.19× | | k8s-multi | value | 1,050,323 | 30 | 31 | 29 | n/a | 15 | 77 | 1.03× | serde_yaml_ng | – | 2.61× | | k8s-multi | typed | 1,050,323 | 41 | 39 | 39 | n/a | 14 | 249 | 0.96× | serde_norway | – | 6.13× | | k8s-multi | sparse | 1,050,323 | 50 | 54 | 55 | n/a | 17 | 1534 | 1.09× | serde_norway | – | 30.60× | | openapi-100k | value | 102,778 | 42 | 42 | 43 | 45 | 17 | 283 | 1.03× | serde_norway | 1.07× | 6.77× | | openapi-100k | typed | 102,778 | 44 | 55 | 54 | 59 | 20 | 294 | 1.26× | serde_yaml_ng | 1.34× | 6.71× | | openapi-100k | sparse | 102,778 | 50 | 67 | 67 | 63 | 17 | 2109 | 1.36× | serde_yaml_ng | 1.27× | 42.47× | | openapi-10m | value | 10,486,088 | 32 | 29 | 28 | 41 | 14 | 120 | 0.90× | serde_yaml_ng | 1.29× | 3.78× | | openapi-10m | typed | 10,486,088 | 39 | 35 | 33 | 51 | 18 | 148 | 0.91× | serde_yaml_ng | 1.31× | 3.81× | | openapi-10m | sparse | 10,486,088 | 51 | 47 | 50 | 65 | 18 | 2208 | 0.97× | serde_norway | 1.28× | 43.31× | | helm-values | value | 102,418 | 139 | 118 | 118 | 151 | 57 | 1197 | 0.85× | serde_norway | 1.08× | 8.58× | | helm-values | typed | 102,418 | 145 | 126 | 128 | 177 | 50 | 1241 | 0.88× | serde_norway | 1.22× | 8.54× | | helm-values | sparse | 102,418 | 169 | 159 | 159 | 207 | 57 | 8485 | 0.94× | serde_norway | 1.22× | 50.13× | ## Serde write The same comparators, each through its own writer with its defaults: | Library | One document | Several documents (k8s-manifest, k8s-multi) | |---|---|---| | **oneyaml** | `value`: `Value::to_yaml_string`; `typed`: `oneyaml::to_string` | `value`: `to_yaml_string` per document, `---` between them (what `Serializer` writes); `typed`: one `oneyaml::Serializer` over a `Vec`, a `serialize` per document | | serde_yaml_ng 0.10.0, serde_norway 0.9.42 | `to_string` | one `Serializer` over a `Vec`, a `serialize` per document (it writes the `---`) | | serde-saphyr 1.3.0 | `to_string` | `to_string_multiple` | | serde_yaml2 0.1.3 | `to_string` | **none**: `n/a` | | serde_json 1.0.151 | `to_string` | `to_string` of the array of documents | `to_writer` is `to_string` plus one `write_all` of the result, so it is not a separate row. Every side uses its default settings (oneyaml's quotes every string that some YAML reader would read as something else). **Workloads**, each on every real-world corpus input at 1× (every document): - `value`: every document as the library's **own** dynamic value, read beforehand by that library (serde_json: from the equivalent JSON), so each library writes its natural in-memory form; - `typed`: every document as the derived structs of `crates/bench/src/models.rs`, read beforehand by oneyaml, so every library writes the same Rust values. Reading the data is preparation, outside the measured function (write, then drop the output); each cell's DHAT baseline prepares the same data and writes nothing. ### Correctness guard (write) `bench ser-check `: each library writes its data, and the output is read back with oneyaml's reader (serde_json's for serde_json), into `Value` for `value` (compared with oneyaml's reading of the input) or into the same structs for `typed` (compared with `==`). A library whose output reads back as something else (a lost or extra document, a changed scalar type, an escape that doesn't read back, an empty collection turned into null) is `differs` and is not measured; oneyaml is checked the same way. An `agrees` cell also shows the output size ÷ the input size. | Case | Workload | oneyaml | serde_yaml_ng | serde_norway | serde_yaml2 | serde-saphyr | serde_json | |---|---|---|---|---|---|---|---| | k8s-manifest | value | agrees (1.06×) | agrees (0.99×) | agrees (0.99×) | n/a [1] | agrees (0.99×) | agrees (0.86×) | | k8s-manifest | typed | agrees (1.16×) | agrees (1.09×) | agrees (1.09×) | n/a [2] | agrees (1.09×) | agrees (0.94×) | | gha-workflow | value | agrees (1.00×) | agrees (0.95×) | agrees (0.95×) | differs [3] | agrees (0.95×) | agrees (0.79×) | | gha-workflow | typed | agrees (1.32×) | agrees (1.23×) | agrees (1.23×) | differs [4] | agrees (1.23×) | agrees (0.99×) | | compose-merge | value | agrees (1.69×) | agrees (1.62×) | agrees (1.62×) | n/a [5] | agrees (1.62×) | agrees (1.37×) | | compose-merge | typed | agrees (1.69×) | agrees (1.63×) | agrees (1.63×) | differs [6] | agrees (1.63×) | agrees (1.36×) | | k8s-multi | value | agrees (1.06×) | agrees (0.99×) | agrees (0.99×) | n/a [7] | agrees (0.99×) | agrees (0.86×) | | k8s-multi | typed | agrees (1.16×) | agrees (1.09×) | agrees (1.09×) | n/a [8] | agrees (1.09×) | agrees (0.94×) | | openapi-100k | value | agrees (1.13×) | agrees (1.11×) | agrees (1.11×) | differs [9] | agrees (1.12×) | agrees (0.83×) | | openapi-100k | typed | agrees (2.36×) | agrees (2.33×) | agrees (2.33×) | differs [10] | agrees (2.33×) | agrees (1.57×) | | openapi-10m | value | agrees (1.13×) | agrees (1.11×) | agrees (1.11×) | differs [11] | agrees (1.11×) | agrees (0.83×) | | openapi-10m | typed | agrees (2.34×) | agrees (2.31×) | agrees (2.31×) | differs [12] | agrees (2.31×) | agrees (1.56×) | | helm-values | value | agrees (0.24×) | agrees (0.24×) | agrees (0.24×) | differs [13] | agrees (0.24×) | agrees (0.22×) | | helm-values | typed | agrees (0.24×) | agrees (0.24×) | agrees (0.24×) | differs [14] | agrees (0.24×) | agrees (0.22×) | 1. serde_yaml2, k8s-manifest value: no multi-document API (writes one document only) 2. serde_yaml2, k8s-manifest typed: no multi-document API (writes one document only) 3. serde_yaml2, gha-workflow value: output does not read back: a ''' inside a single-quoted scalar ends it early at jobs.delta-0.steps[2].with (81:48) 4. serde_yaml2, gha-workflow typed: output does not read back: a ''' inside a single-quoted scalar ends it early at jobs.bravo-2.steps[2].with (95:48) 5. serde_yaml2, compose-merge value: no merge-key support 6. serde_yaml2, compose-merge typed: output does not read back: invalid type: unit value, expected a map at services.db.environment (75:18) 7. serde_yaml2, k8s-multi value: no multi-document API (writes one document only) 8. serde_yaml2, k8s-multi typed: no multi-document API (writes one document only) 9. serde_yaml2, openapi-100k value: doc 0 /components/schemas/delta0/properties/ratio/example: 0 (ours 0.0) 10. serde_yaml2, openapi-100k typed: output does not read back: invalid type: unit value, expected a map at paths./alphas/107/{id}.parameters[0].schema.properties (37:20) 11. serde_yaml2, openapi-10m value: doc 0 /components/schemas/delta0/properties/ratio/example: 0 (ours 0.0) 12. serde_yaml2, openapi-10m typed: output does not read back: invalid type: unit value, expected a map at paths./alphas/10011/{id}.parameters[0].schema.properties (37:20) 13. serde_yaml2, helm-values value: doc 0 /alpha11/affinity: null (ours {}) 14. serde_yaml2, helm-values typed: output does not read back: invalid type: unit value, expected a map (1:1) ### Instructions (write) Lower is better; columns as in the read table. | Case | Workload | Bytes | oneyaml Ir/B | serde_yaml_ng Ir/B | serde_norway Ir/B | serde_yaml2 Ir/B | serde-saphyr Ir/B | serde_json Ir/B | oneyaml ÷ fastest feature-complete (target applies) | fastest feature-complete | target ≤ 1.25× | oneyaml ÷ serde_yaml2 (partial, no target) | oneyaml ÷ serde_json | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | 124.0 | 230.0 | 230.0 | n/a | 195.0 | 16.4 | 0.64× | serde-saphyr | met | – | 7.54× | | k8s-manifest | typed | 5,633 | 144.5 | 248.0 | 248.0 | n/a | 208.8 | 14.5 | 0.69× | serde-saphyr | met | – | 9.94× | | gha-workflow | value | 3,078 | 88.6 | 184.3 | 184.3 | differs | 152.6 | 13.6 | 0.58× | serde-saphyr | met | – | 6.53× | | gha-workflow | typed | 3,078 | 128.8 | 242.8 | 242.8 | differs | 186.6 | 15.0 | 0.69× | serde-saphyr | met | – | 8.61× | | compose-merge | value | 2,442 | 192.5 | 369.1 | 369.1 | n/a | 335.5 | 26.4 | 0.57× | serde-saphyr | met | – | 7.29× | | compose-merge | typed | 2,442 | 236.7 | 363.5 | 363.5 | differs | 330.2 | 22.8 | 0.72× | serde-saphyr | met | – | 10.38× | | k8s-multi | value | 1,050,323 | 124.4 | 228.7 | 228.7 | n/a | 193.6 | 16.2 | 0.64× | serde-saphyr | met | – | 7.66× | | k8s-multi | typed | 1,050,323 | 142.5 | 246.1 | 246.1 | n/a | 206.9 | 13.7 | 0.69× | serde-saphyr | met | – | 10.41× | | openapi-100k | value | 102,778 | 106.7 | 225.8 | 225.8 | differs | 180.1 | 16.2 | 0.59× | serde-saphyr | met | – | 6.57× | | openapi-100k | typed | 102,778 | 218.6 | 425.5 | 425.5 | differs | 305.6 | 24.7 | 0.72× | serde-saphyr | met | – | 8.86× | | openapi-10m | value | 10,486,088 | 104.9 | 223.9 | 223.9 | differs | 178.2 | 15.7 | 0.59× | serde-saphyr | met | – | 6.69× | | openapi-10m | typed | 10,486,088 | 215.4 | 420.5 | 420.5 | differs | 301.7 | 23.2 | 0.71× | serde-saphyr | met | – | 9.30× | | helm-values | value | 102,418 | 26.5 | 56.1 | 56.1 | differs | 42.3 | 4.1 | 0.63× | serde-saphyr | met | – | 6.51× | | helm-values | typed | 102,418 | 32.2 | 55.1 | 55.1 | differs | 42.4 | 3.5 | 0.76× | serde-saphyr | met | – | 9.32× | ### Allocations (write) **Allocation count (blocks, `realloc` included; baseline subtracted)** | Case | Workload | Bytes | oneyaml | serde_yaml_ng | serde_norway | serde_yaml2 | serde-saphyr | serde_json | oneyaml ÷ best feature-complete | best feature-complete | oneyaml ÷ serde_yaml2 (partial) | |---|---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | 153 | 492 | 492 | n/a | 839 | 6 | 0.31× | serde_yaml_ng | – | | k8s-manifest | typed | 5,633 | 126 | 537 | 537 | n/a | 899 | 6 | 0.23× | serde_yaml_ng | – | | gha-workflow | value | 3,078 | 19 | 177 | 177 | differs | 334 | 5 | 0.11× | serde_yaml_ng | – | | gha-workflow | typed | 3,078 | 59 | 285 | 285 | differs | 443 | 5 | 0.21× | serde_yaml_ng | – | | compose-merge | value | 2,442 | 19 | 329 | 329 | n/a | 618 | 5 | 0.058× | serde_yaml_ng | – | | compose-merge | typed | 2,442 | 95 | 324 | 324 | differs | 608 | 5 | 0.29× | serde_yaml_ng | – | | k8s-multi | value | 1,050,323 | 27,718 | 88,499 | 88,499 | n/a | 152,646 | 13 | 0.31× | serde_yaml_ng | – | | k8s-multi | typed | 1,050,323 | 21,033 | 96,794 | 96,794 | n/a | 163,707 | 13 | 0.22× | serde_yaml_ng | – | | openapi-100k | value | 102,778 | 26 | 8,097 | 8,097 | differs | 15,171 | 10 | 0.0032× | serde_yaml_ng | – | | openapi-100k | typed | 102,778 | 1,702 | 17,127 | 17,127 | differs | 25,516 | 11 | 0.099× | serde_yaml_ng | – | | openapi-10m | value | 10,486,088 | 39 | 812,544 | 812,544 | differs | 1,528,642 | 17 | 0.000048× | serde_yaml_ng | – | | openapi-10m | typed | 10,486,088 | 168,570 | 1,721,470 | 1,721,470 | differs | 2,569,995 | 17 | 0.098× | serde_yaml_ng | – | | helm-values | value | 102,418 | 21 | 1,965 | 1,965 | differs | 3,083 | 8 | 0.011× | serde_yaml_ng | – | | helm-values | typed | 102,418 | 90 | 1,965 | 1,965 | differs | 3,083 | 8 | 0.046× | serde_yaml_ng | – | **Bytes allocated ÷ YAML input bytes (baseline subtracted)** | Case | Workload | Bytes | oneyaml | serde_yaml_ng | serde_norway | serde_yaml2 | serde-saphyr | serde_json | oneyaml ÷ best feature-complete | best feature-complete | oneyaml ÷ serde_yaml2 (partial) | |---|---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | 11.35 | 13.75 | 13.75 | n/a | 9.17 | 2.88 | 1.24× | serde-saphyr | – | | k8s-manifest | typed | 5,633 | 6.99 | 14.00 | 14.00 | n/a | 8.76 | 2.88 | 0.80× | serde-saphyr | – | | gha-workflow | value | 3,078 | 4.18 | 18.83 | 18.83 | differs | 6.31 | 2.60 | 0.66× | serde-saphyr | – | | gha-workflow | typed | 3,078 | 4.49 | 19.56 | 19.56 | differs | 8.08 | 2.60 | 0.56× | serde-saphyr | – | | compose-merge | value | 2,442 | 9.46 | 24.88 | 24.88 | n/a | 12.93 | 3.28 | 0.73× | serde-saphyr | – | | compose-merge | typed | 2,442 | 11.42 | 24.86 | 24.86 | differs | 12.71 | 3.28 | 0.90× | serde-saphyr | – | | k8s-multi | value | 1,050,323 | 10.36 | 3.78 | 3.78 | n/a | 7.97 | 2.00 | 2.74× | serde_yaml_ng | – | | k8s-multi | typed | 1,050,323 | 5.28 | 4.00 | 4.00 | n/a | 9.78 | 2.00 | 1.32× | serde_yaml_ng | – | | openapi-100k | value | 102,778 | 3.86 | 4.16 | 4.16 | differs | 8.69 | 2.55 | 0.93× | serde_yaml_ng | – | | openapi-100k | typed | 102,778 | 7.84 | 8.01 | 8.01 | differs | 13.51 | 5.10 | 0.98× | serde_yaml_ng | – | | openapi-10m | value | 10,486,088 | 4.43 | 4.46 | 4.46 | differs | 9.44 | 3.20 | 0.99× | serde_yaml_ng | – | | openapi-10m | typed | 10,486,088 | 9.01 | 8.94 | 8.94 | differs | 14.71 | 3.20 | 1.01× | serde_yaml_ng | – | | helm-values | value | 102,418 | 0.93 | 1.31 | 1.31 | differs | 1.65 | 0.64 | 0.71× | serde_yaml_ng | – | | helm-values | typed | 102,418 | 0.86 | 1.31 | 1.31 | differs | 1.65 | 0.64 | 0.66× | serde_yaml_ng | – | **Peak heap added by writing ÷ YAML input bytes (whole-process peak minus the baseline's, which holds the prepared data)** | Case | Workload | Bytes | oneyaml | serde_yaml_ng | serde_norway | serde_yaml2 | serde-saphyr | serde_json | |---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | ≤ prep | ≤ prep | ≤ prep | n/a | ≤ prep | 0.58 | | k8s-manifest | typed | 5,633 | ≤ prep | 7.07 | 7.07 | n/a | ≤ prep | ≤ prep | | gha-workflow | value | 3,078 | ≤ prep | ≤ prep | ≤ prep | differs | ≤ prep | 0.50 | | gha-workflow | typed | 3,078 | ≤ prep | 13.97 | 13.97 | differs | ≤ prep | ≤ prep | | compose-merge | value | 2,442 | ≤ prep | ≤ prep | ≤ prep | n/a | ≤ prep | 0.31 | | compose-merge | typed | 2,442 | ≤ prep | 10.81 | 10.81 | differs | ≤ prep | ≤ prep | | k8s-multi | value | 1,050,323 | 0.25 | 0.14 | 0.14 | n/a | 0.22 | 0.14 | | k8s-multi | typed | 1,050,323 | 0.30 | 0.25 | 0.25 | n/a | 0.98 | ≤ prep | | openapi-100k | value | 102,778 | 0.95 | ≤ prep | ≤ prep | differs | ≤ prep | 0.45 | | openapi-100k | typed | 102,778 | 1.25 | 1.49 | 1.49 | differs | 0.99 | 0.99 | | openapi-10m | value | 10,486,088 | 1.37 | ≤ prep | ≤ prep | differs | 0.56 | 0.77 | | openapi-10m | typed | 10,486,088 | 1.95 | 1.72 | 1.72 | differs | 1.71 | 0.11 | | helm-values | value | 102,418 | ≤ prep | ≤ prep | ≤ prep | differs | ≤ prep | ≤ prep | | helm-values | typed | 102,418 | ≤ prep | ≤ prep | ≤ prep | differs | ≤ prep | ≤ prep | The third table replaces the read path's whole-process peak: the baseline's peak is the prepared data, so the difference is what writing adds at its peak, the output plus the writer's working set. `≤ prep` means writing never rose above the preparation's peak, so DHAT can't tell what it added. This table has no ratio columns: a hidden cell could be below any visible one. ### Wall-clock (write, informational only) Higher MB/s is better (MB of YAML input, for every library); the ratio columns are time ratios. Host: AMD Ryzen 9 7950X3D 16-Core Processor; load average (1/5/15 min) at start: 11.69 / 10.09 / 9.18. MB/s = 10⁶ bytes/s, median of ≥ 5 runs. | Case | Workload | Bytes | oneyaml MB/s | serde_yaml_ng MB/s | serde_norway MB/s | serde_yaml2 MB/s | serde-saphyr MB/s | serde_json MB/s | oneyaml ÷ fastest feature-complete | fastest feature-complete | oneyaml ÷ serde_yaml2 (partial) | oneyaml ÷ serde_json | |---|---|---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | value | 5,633 | 168 | 85 | 86 | n/a | 106 | 1209 | 0.63× | serde-saphyr | – | 7.18× | | k8s-manifest | typed | 5,633 | 127 | 81 | 82 | n/a | 97 | 1322 | 0.77× | serde-saphyr | – | 10.42× | | gha-workflow | value | 3,078 | 151 | 112 | 109 | differs | 86 | 971 | 0.74× | serde_yaml_ng | – | 6.45× | | gha-workflow | typed | 3,078 | 91 | 41 | 43 | differs | 59 | 816 | 0.65× | serde-saphyr | – | 8.97× | | compose-merge | value | 2,442 | 58 | 28 | 27 | n/a | 60 | 806 | 1.04× | serde-saphyr | – | 13.93× | | compose-merge | typed | 2,442 | 81 | 59 | 55 | differs | 61 | 969 | 0.76× | serde-saphyr | – | 11.98× | | k8s-multi | value | 1,050,323 | 96 | 68 | 67 | n/a | 61 | 1005 | 0.71× | serde_yaml_ng | – | 10.43× | | k8s-multi | typed | 1,050,323 | 119 | 79 | 78 | n/a | 88 | 1144 | 0.74× | serde-saphyr | – | 9.64× | | openapi-100k | value | 102,778 | 201 | 81 | 67 | differs | 83 | 1227 | 0.41× | serde-saphyr | – | 6.12× | | openapi-100k | typed | 102,778 | 85 | 46 | 46 | differs | 65 | 834 | 0.77× | serde-saphyr | – | 9.84× | | openapi-10m | value | 10,486,088 | 99 | 77 | 76 | differs | 61 | 280 | 0.78× | serde_yaml_ng | – | 2.82× | | openapi-10m | typed | 10,486,088 | 66 | 45 | 43 | differs | 58 | 401 | 0.87× | serde-saphyr | – | 6.03× | | helm-values | value | 102,418 | 934 | 392 | 388 | differs | 484 | 5533 | 0.52× | serde-saphyr | – | 5.92× | | helm-values | typed | 102,418 | 609 | 383 | 391 | differs | 470 | 6007 | 0.77× | serde-saphyr | – | 9.87× | ## `Document` yaml-edit 0.3.2 is the comparator: the other lossless YAML editor for Rust. Both sides run on the real-world corpus at 1×; oneyaml under the default settings with `Limits::unlimited()`, as in the parser tables. - *parse*: oneyaml `Stream::parse_with(input, &settings)`, the strict entry point (a single-document input costs the same through `Document::parse_with`): the syntax tree plus the meaning check, so that `Document::parse` fails exactly where `from_str::` fails (duplicate keys, merge values, aliases, tags). yaml-edit: `YamlFile::parse`, which checks syntax only. The result is dropped; neither side walks it. - *emit*: `to_string()` of the parsed, untouched document (parsing is preparation, not measured). The *emit = input* column checks that the output is byte-identical to the input, for both libraries. - *retained*: parse, then keep the result, so DHAT's end-of-run heap (net of the baseline's) is what a parsed document keeps, including its own copy of the input. *parse blocks* are the allocations of a parse. **Instructions (Ir/B; lower is better)** | Case | Bytes | parse oneyaml | parse yaml-edit | parse ÷ yaml-edit | parity | emit oneyaml | emit yaml-edit | emit ÷ yaml-edit | parity | emit = input (oneyaml / yaml-edit) | |---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 400.3 | 495.2 | 0.81× | met | 47.3 | 442.9 | 0.11× | met | = / = | | gha-workflow | 3,078 | 286.0 | 417.5 | 0.68× | met | 35.0 | 322.8 | 0.11× | met | = / = | | compose-merge | 2,442 | 444.0 | 512.9 | 0.87× | met | 54.9 | 441.6 | 0.12× | met | = / = | | k8s-multi | 1,050,323 | 385.4 | 435.2 | 0.89× | met | 46.0 | 436.9 | 0.11× | met | = / = | | openapi-100k | 102,778 | 399.2 | 449.0 | 0.89× | met | 49.2 | 485.4 | 0.10× | met | = / = | | openapi-10m | 10,486,088 | 389.9 | 438.6 | 0.89× | met | 47.7 | 477.8 | 0.100× | met | = / = | | helm-values | 102,418 | 135.8 | 196.0 | 0.69× | met | 24.1 | 146.7 | 0.16× | met | = / = | **Memory (DHAT; parse allocations net of the input-generation baseline; retained = the parsed stream/file kept, net of the baseline's end-of-run heap, including the parser's copy of the input where it keeps one)** | Case | Bytes | parse blocks oneyaml | parse blocks yaml-edit | retained ÷ input oneyaml | retained ÷ input yaml-edit | target: retained ≤ 8× (≥ 100 KB) | |---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 104 | 2,454 | 4.79 | 10.76 | no target (< 100 KB) | | gha-workflow | 3,078 | 97 | 1,062 | 4.06 | 8.31 | no target (< 100 KB) | | compose-merge | 2,442 | 121 | 1,221 | 5.41 | 11.73 | no target (< 100 KB) | | k8s-multi | 1,050,323 | 1,791 | 385,968 | 4.47 | 8.47 | met | | openapi-100k | 102,778 | 225 | 40,298 | 4.38 | 9.12 | met | | openapi-10m | 10,486,088 | 9,214 | 4,014,717 | 4.31 | 8.88 | met | | helm-values | 102,418 | 94 | 16,284 | 2.44 | 3.16 | met | Host: AMD Ryzen 9 7950X3D 16-Core Processor; load average (1/5/15 min) at start: 9.18 / 9.66 / 9.09. Median of ≥ 5 runs, in ms. **Wall-clock (informational only)** | Case | Bytes | parse ms oneyaml | parse ms yaml-edit | parse ÷ yaml-edit | emit ms oneyaml | emit ms yaml-edit | emit ÷ yaml-edit | emit ÷ parse (oneyaml) | |---|---|---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 0.123 | 0.149 | 0.83× | 0.012 | 0.132 | 0.091× | 0.098× | | gha-workflow | 3,078 | 0.049 | 0.066 | 0.75× | 0.005 | 0.053 | 0.095× | 0.10× | | compose-merge | 2,442 | 0.061 | 0.063 | 0.98× | 0.006 | 0.056 | 0.11× | 0.10× | | k8s-multi | 1,050,323 | 25.241 | 28.446 | 0.89× | 1.978 | 26.024 | 0.076× | 0.078× | | openapi-100k | 102,778 | 2.445 | 2.571 | 0.95× | 0.201 | 2.666 | 0.076× | 0.082× | | openapi-10m | 10,486,088 | 263.656 | 356.068 | 0.74× | 22.930 | 286.922 | 0.080× | 0.087× | | helm-values | 102,418 | 0.731 | 1.115 | 0.65× | 0.108 | 0.759 | 0.14× | 0.15× | ### Edits yaml-edit has a comparable edit API (`Document::set`/`remove` on its rowan CST). - *script*: a fixed script on the first document's root mapping: insert a new last entry `bench_edit_key: x`, replace its value with `'y'`, remove it, on a parsed document (parsing is preparation, not measured). oneyaml: `Stream::document_mut(0)`, `insert`, `set_path`, `remove`; yaml-edit: `Document::set` twice, `Document::remove`. The instruction columns are totals for one script on a fresh parse, not per byte: a local edit should cost the same at any document size. The script leaves the document as it found it, so the wall-clock columns repeat it on one tree. - *round*: parse + script + `to_string`, from the input text. - *output*: `=` when the text after the round is byte-identical to the input. **Instructions (lower is better)** | Case | Bytes | script Ir oneyaml | script Ir yaml-edit | script ÷ yaml-edit | parity | round Ir/B oneyaml | round Ir/B yaml-edit | round ÷ yaml-edit | parity | output = input (oneyaml / yaml-edit) | |---|---|---|---|---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 562,029 | 1,058,947 | 0.53× | met | 552.0 | 1126.2 | 0.49× | met | = / = | | gha-workflow | 3,078 | 826,673 | 1,870,115 | 0.44× | met | 595.2 | 1348.6 | 0.44× | met | = / = | | compose-merge | 2,442 | 503,520 | 302,397 | 1.67× | **missed** | 712.4 | 1079.5 | 0.66× | met | = / = | | k8s-multi | 1,050,323 | 559,559 | 1,786,870 | 0.31× | met | 436.8 | 873.7 | 0.50× | met | = / = | | openapi-100k | 102,778 | 584,641 | 37,349,309 | 0.016× | met | 459.1 | 1297.3 | 0.35× | met | = / = | | openapi-10m | 10,486,088 | 589,638 | 3,717,124,522 | 0.00016× | met | 442.6 | 1270.6 | 0.35× | met | = / = | | helm-values | 102,418 | 1,192,304 | 11,824,702 | 0.10× | met | 173.7 | 457.5 | 0.38× | met | = / = | Host: AMD Ryzen 9 7950X3D 16-Core Processor; load average (1/5/15 min) at start: 10.72 / 9.90 / 9.20. Median of ≥ 5 runs, in ms. **Wall-clock (informational only)** | Case | Bytes | script ms oneyaml | script ms yaml-edit | script ÷ yaml-edit | round ms oneyaml | round ms yaml-edit | round ÷ yaml-edit | |---|---|---|---|---|---|---|---| | k8s-manifest | 5,633 | 0.039 | 0.064 | 0.61× | 0.203 | 0.371 | 0.55× | | gha-workflow | 3,078 | 0.065 | 0.164 | 0.40× | 0.220 | 0.364 | 0.60× | | compose-merge | 2,442 | 0.059 | 0.027 | 2.22× | 0.201 | 0.245 | 0.82× | | k8s-multi | 1,050,323 | 0.041 | 0.172 | 0.24× | 29.375 | 58.134 | 0.51× | | openapi-100k | 102,778 | 0.041 | 2.175 | 0.019× | 2.869 | 8.138 | 0.35× | | openapi-10m | 10,486,088 | 0.040 | 224.429 | 0.00018× | 284.433 | 866.035 | 0.33× | | helm-values | 102,418 | 0.097 | 1.628 | 0.060× | 1.323 | 4.634 | 0.29× | # yaml-test-suite conformance: oneyaml vs. other Rust YAML parsers Measured on 2026-10-21 at workspace revision `e24c4f8`, on the vendored yaml-test-suite (release `data-2022-01-17`, commit `6e6c296a`; 402 cases: 308 valid, 94 error; see `crates/test-suite/data/yaml-test-suite/SOURCE.md`), with rustc 1.97.1. Reproduce (the tables below are this command's output, verbatim): ```sh cargo run -p oracles --release # the tables cargo run -p oracles --release -- --failures libyaml # every failure, with input/expected/actual cargo run -p oracles --release -- --prefix saphyr # error cases whose pre-error events differ cargo run -p oracles --release -- --case 652Z # one case, every parser cargo run -p oracles --release -- --readme # the crate README's conformance line cargo run -p oracles --release -- --readme --ref v1.0.0 # the same, on a tree `bench pin-links` pinned to v1.0.0 ``` The pass counts in `crates/oneyaml/README.md` (between ``) are generated, never typed: `--readme` writes them from this same run of the standard scorer for `oneyaml-syntax`, the strict harness run and the release recorded in `SOURCE.md`, and a test in `crates/oracles/tests/scores.rs` fails when the README's line differs from what a fresh run would write. `crates/oracles/tests/scores.rs` pins every number below, so a change shows up in `cargo test`. ## Parsers under test | Parser | Crate and exact version | What is measured | |---|---|---| | oneyaml-syntax | this workspace, `oneyaml_syntax::parse` | events, classification, round trip | | saphyr | `saphyr-parser = "=0.1.0"` (the event parser under `saphyr`) | events, classification | | yaml-rust2 | `yaml-rust2 = "=0.13.0"`, `parser::Parser::next_token` | events, classification | | libyaml | `unsafe-libyaml = "=0.2.11"` (c2rust translation of libyaml at commit `2c891fc7`; what `serde_yaml` and `serde_yaml_ng` 0.10.0 parse with) | events, classification | | yaml-edit | `yaml-edit = "=0.3.2"`, `YamlFile::parse` (+ optional `validator::Validator`) | classification, round trip | All are the newest crates.io releases at the time of measurement. They are dependencies of the unpublished `oracles` crate only; `cargo tree -i oracles --workspace` shows nothing depends on it, and no published crate's `cargo tree -e all` contains any of them. ## Results: standard criterion The criterion other projects publish (and the one matrix.yaml.info uses): * **valid case:** no error, and the event tree equals `test.event`; * **error case:** the parser reports an error (any error, anywhere). | Parser | Version | Valid | Error | Total | Error + prefix (vendored / corrected) | Notes | |---|---|---|---|---|---|---| | oneyaml-syntax | 0.2.0 | 308/308 | 94/94 | 402/402 | 83 / 94 of 94 | | | saphyr-parser | 0.1.0 | 308/308 | 94/94 | 402/402 | 55 / 55 of 94 | API lacks `...` marker, flow `{}`/`[]` markers, anchor names (compared by order); ignored | | yaml-rust2 | 0.13.0 | 308/308 | 94/94 | 402/402 | 57 / 56 of 94 | API lacks `---` marker, `...` marker, flow `{}`/`[]` markers, anchor names (compared by order); ignored | | libyaml (unsafe-libyaml) | 0.2.11 | 252/308 | 78/94 | 330/402 | 50 / 55 of 94 | | | yaml-edit | 0.3.2 | n/a | 25/94 | n/a | n/a | no event API: valid column is n/a (see classification table) | | yaml-edit + Validator | 0.3.2 | n/a | 94/94 | n/a | n/a | no event API: valid column is n/a (see classification table) | ## Results: classification and round trip Every parser, including yaml-edit, can be held to these. | Parser | Accepts valid | Rejects invalid | Both | Round trip (valid) | |---|---|---|---|---| | oneyaml-syntax | 308/308 | 94/94 | 402/402 | 308/308 | | saphyr-parser | 308/308 | 94/94 | 402/402 | not measured | | yaml-rust2 | 308/308 | 94/94 | 402/402 | not measured | | libyaml (unsafe-libyaml) | 257/308 | 78/94 | 335/402 | not measured | | yaml-edit | 308/308 | 25/94 | 333/402 | 308/308 | | yaml-edit + Validator | 290/308 | 94/94 | 384/402 | 308/308 | ## Our strict score (not comparable; shown separately) oneyaml-syntax strict (contract + exact error prefixes + pinned codes): **402/402** (valid 308/308, error 94/94; 11 judged against corrected expectations). The parser keeps a document open until its end marker, the next document start or the end of the stream (YAML 1.2.2 section 9.1), whatever the style of its root, so content after a complete root is an error inside the document and no `-DOC` comes before it. The recorded error trees do not all agree with that: * BS4K and EB22 (a plain scalar root followed by content on a later line) record `-DOC` before the error. Their corrected expectations, listed in `known-defects.txt`, drop it, and the parser matches the corrected ones. * 4H7K (`[ a, b, c ] ]`) records `-DOC` before the error on the extra `]`. The same rule applies: the document is still open when the `]` is read, so its corrected expectation drops the `-DOC` (and restores the missing `[]` marker), and the parser matches it. The spec reference parser and yaml-rust2 close the document as soon as the root sequence ends, which is where the recorded `-DOC` comes from. * 9JBA (`[ a, b, c, ]#invalid`) and KS4U (a flow root followed by `invalid item` on the next line) record no `-DOC`, although other parsers emit one there; the parser matches the recorded trees. This is `cargo run -p test-suite -- --producer parser`: every stream must also pass the lossless event contract (`check_contract`), error cases must emit exactly the `test.event` prefix before the first error, and every error case's diagnostic codes are pinned. No competitor emits our lossless events, so none can be scored this way. ## Known deviations This section is the one home for what oneyaml reads differently from YAML 1.2.2; the README and the migration guide link here. **With default settings: none known.** The strict score has no failures. Cases whose recorded expectations are themselves defective are corrected, with reasons, in [`crates/test-suite/known-defects.txt`](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/test-suite/known-defects.txt) (see *Methodology*). **Opt-in departures.** Every behaviour that departs from YAML 1.2.2 is a named `Settings` method, **off by default**: | Setting | What it changes | On in | |---|---|---| | `merge_keys` | `<<` merges mappings (YAML 1.1 merge-key extension) | `Compat::yaml_1_1()` | | `yaml11_booleans` | `yes`/`no`/`on`/`off`/`y`/`n`, … are booleans | `Compat::yaml_1_1()` | | `legacy_octal` | `0777` is octal | `Compat::yaml_1_1()` | | `sexagesimal` | `12:30` is a base-60 number | `Compat::yaml_1_1()` | | `digit_separators` | `1_000` is a number | `Compat::yaml_1_1()` | | `yaml11_int_forms` | `0b1010` and signed hexadecimal (`-0x1F`) are integers | `Compat::yaml_1_1()`, `Compat::serde_yaml()` | | `honor_version_directive` | `%YAML 1.1` switches its document to the `Compat::yaml_1_1()` behaviours (otherwise: read as 1.2, with a warning) | none | | `resolve_tagged_variant_content` | a typed enum reads `!Square 2.0` as `Square(2.0)` | `Compat::serde_yaml()` | | `lenient_inline_colons` | a same-line `: ` inside an unquoted block value is text, always with a warning (otherwise: error `Y0027` with a quoting fix) | none | The YAML 1.1 settings only add meanings on top of the Core schema. The settings themselves are documented on [`Settings`](https://docs.rs/oneyaml/latest/oneyaml/struct.Settings.html); how they differ from `serde_yaml`'s reading is in the [migration guide](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/migrating-from-serde-yaml.md#divergences). **Known defect.** Under `lenient_inline_colons` only, one rare shape of recovered input (a failed directive block next to a byte order mark) can let an edit through that reads back differently; the fuzzers count it, and it is described in [crates/fuzz/README.md](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/fuzz/README.md). ## Methodology * **One scorer for everyone.** `test_suite::standard::score` runs any `Oracle` over the suite. Each adapter only converts the parser's own events to suite-notation lines; scoring, normalisation, and panic handling are shared. A panic is caught and scored as a failure (a panic is not a reported error). So is a run that the adapter cuts off at its event cap (100,000 events) before the parser reaches the end of the stream or reports an error: that run is "truncated", not a rejection. There were no panics and no truncated runs. * **Normalisation by API profile.** Some event APIs cannot express parts of the notation (saphyr: `...`, flow style, anchor names; yaml-rust2: also `---`). Each adapter declares a `Profile`; only the absent information is dropped, from **both** the expected and actual lines. Anchors are then compared structurally: `&name` becomes `&N` by definition order and aliases point at the latest definition of that name. The line tokeniser only reads properties before the value, so a `&` inside a scalar value is never renumbered. oneyaml-syntax and libyaml are compared with nothing dropped. So saphyr's and yaml-rust2's 402/402 are **weaker claims** than ours or libyaml's: they do not cover document markers, flow/block style, or anchor spelling. * **"Error + prefix" (not part of the standard verdict).** For rejected error cases, it checks that the events emitted before the error equal `test.event`: first the vendored file, then with the 11 `known-defects.txt` corrections applied. This column exists so the corrections cannot hide anything. **The corrections cannot affect the standard columns:** all 11 corrected cases are error cases, and the standard error verdict ignores events. For us the vendored and corrected results differ on all 11 corrected cases (83 vs 94, asserted in `crates/test-suite/tests/standard.rs`). For streaming parsers the prefix mostly measures lookahead (how far they read before reporting), so read it as information, not correctness. * **yaml-edit.** It has no event API, and no event mapping was invented for it. What it does expose is measured: whether `YamlFile::parse` reports errors, and whether `parse(input).tree().to_string()` is byte-identical to the input for valid cases. Its parser is lenient by design and it ships a separate strict `validator::Validator`, so a second row adds error-severity violations from `Validator::new().validate_syntax(root)` (warnings are ignored). That is the documented whole-tree entry point and includes the directive checks. * **Our round trip** is the concatenation of every event's source slice (contract C1), compared byte for byte with `in.yaml`. * **Inputs.** All 402 `in.yaml` files are UTF-8 and none has a BOM. libyaml's own encoding detection is left on. Forcing UTF-8, as `serde_yaml` does, gives the same scores. ## Adapter checks Every competitor failure was classified by hand from `--failures` output: * **libyaml:** its 72 failures are 51 valid inputs rejected with libyaml errors (e.g. 2JQS "did not find expected key", 2SXE anchors containing `:`, tab-related cases, reserved directives, 4MUZ/5MUD flow `:` on the next line), 16 error inputs accepted, and 5 valid inputs accepted with different events. All 5 were checked by hand and all are libyaml's behaviour: 652Z and HM87/01 (`?foo` in flow read as an explicit key), Y2GN (`&an:chor` split at the `:`), and JEF9/02 and L24T/01 (trailing spaces in a block scalar at EOF without a final newline). As an independent check, our libyaml numbers equal matrix.yaml.info's `c-libyaml-event` row exactly (below). That row is scored against the same suite commit with its own printer. * **saphyr, yaml-rust2:** there are no standard failures to classify. A systematic adapter bug in tag, anchor, or scalar-style formatting would have shown up as valid-case mismatches. Tag handling was spot-checked on 6CK3 (`%TAG`, `!local`, `!e!tag%21`), and a sample of prefix mismatches (2CMS, 3HFZ, 4H7K) was checked: all are lookahead or timing differences. * **yaml-edit:** the 69 accepted invalid inputs have no parse errors, and the 18 valid inputs the Validator rejects are the Validator's own messages: tabs flagged as indentation (6HB6, DK95/00, /04, /05, Y79Y/002), "Directive without document content" (MUS6/02–06, DK95/07), duplicate or ordering rules on multi-document directives (5TYM, W4TN, 6WLZ, 9WXW), 2JQS "Duplicate key" on two empty keys, 9MQT/00 `...` in a quoted scalar, and C4HZ indentation. * **No adapter bugs were found.** No event conversion was changed after the first full run. The only later addition was the `yaml-edit + Validator` row. The adapters for libyaml, saphyr, and yaml-rust2 each have a unit test rendering anchors, tags, styles, and an error. ## Published figures (matrix.yaml.info) matrix.yaml.info (fetched 2026-09-27) was generated from yaml-test-suite commit `6e6c296a` (2022-01-17), the same data we vendor. It compares full event output, and its error criterion is "reports an error", like ours. | matrix.yaml.info row | Valid ok | n/i | diff | error | Invalid pass / fail | Total pass | Our figure | |---|---|---|---|---|---|---|---| | `c-libyaml-event` (C libyaml) | 252 | 9 | 5 | 42 | 78 / 16 | 330 | libyaml: 252 / 78 / 330, **identical** (our 5 "accepted, events differ" = their 5 diff; our 51 rejections = their 9 n/i + 42 error) | | `rust-yamlrust-event` (yaml-rust, the original crate) | 251 | – | 28 | 29 | 66 / 28 | 317 | not comparable: yaml-rust2 is the maintained fork, 402/402 here (with its profile's normalisation) | saphyr, yaml-rust2, yaml-edit, and oneyaml are not in the matrix. Its reference parser and libfyaml score 402/402 there. **Where our numbers differ from matrix-style reporting, and why:** they don't, under the standard criterion. The known-defect corrections change only error-case prefixes, which the standard criterion does not check. Our 402/402 is the same number a matrix run would report. The only place the corrections show is the informational prefix column (83 vendored vs 94 corrected for us). ## Observations * saphyr-parser 0.1.0 and yaml-rust2 0.13.0 now classify every case correctly and match every valid event tree, as far as their event APIs can express it. yaml-rust (the original) was at 317 on the matrix. * yaml-rust2 and libyaml reproduce the recorded 4H7K prefix: they emit `-DOC` before failing on `[ a, b, c ] ]`. The spec reference parser (YAML::Parser 0.0.5 via YAML::PP::Ref 0.02) emits it too. The correction drops that `-DOC`, because the document is still open when the extra `]` is read, and restores the missing `[]` marker (see "Our strict score"). yaml-rust2 cannot express the marker, so 4H7K counts in its vendored figure (57) and not in its corrected one (56). libyaml can express the marker, but its `-DOC` still differs from the correction, so neither of its figures counts 4H7K (50 / 55). We match the correction, and saphyr reports the error before the sequence. * libyaml 0.2.5-era behaviour is unchanged: 330/402, the same as the C library on the matrix. This is the parser behind `serde_yaml` and `serde_yaml_ng`. * yaml-edit round-trips every valid input byte for byte, as we do. Its parser alone rejects 25/94 invalid inputs. With the Validator it rejects all 94 but also 18 valid ones. # Changelog All notable changes to the published crates (`oneyaml`, `oneyaml-core`, `oneyaml-syntax`). The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [semver](https://semver.org/). `oneyaml` and `oneyaml-core` share a version; `oneyaml-syntax` has its own, looser track (its syntax tree and event stream may change in a minor release). Within a major version, default output bytes change only to fix incorrect output or to follow the documented edit and quoting rules, and every such change is listed in the release's *Upgrading* notes. Breaking changes are flagged **Breaking**; an MSRV change is always announced here (the MSRV policy is in the [README](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/oneyaml/README.md#minimum-supported-rust-version)). Moving from `serde_yaml`, `serde_yaml_ng` or `serde_norway`? See the [migration guide](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/migrating-from-serde-yaml.md). ## 1.2.0 — 2026-10-21 `oneyaml` 1.2.0, `oneyaml-core` 1.2.0, `oneyaml-syntax` 0.2.0. MSRV unchanged (Rust 1.85). No public item is removed or changed, so nothing here is flagged **Breaking**; `Y1005` is deprecated. `oneyaml-syntax` moves to 0.2 because its event stream changed for some inputs (see *Changed*) and it has a new public method; `oneyaml` 1.2.0 needs it, and a project still on `oneyaml` 1.1.0 keeps `oneyaml-syntax` 0.1.2 after `cargo update`. **Upgrading from 1.1.** No public item is removed or changed, so code that builds against 1.1 builds against 1.2. Check these if you compare output bytes or match diagnostic codes: - Default output (each follows the documented edit or quoting rules, or fixes output; details under *Changed*): - Edits write `no`, `yes`, `on`, `y` and `n` plain when the document is read with the default Core schema, under which they are strings: `restart: no`, where 1.1 wrote `restart: 'no'`. YAML 1.1 settings and `%YAML 1.1` documents keep the quotes on new entries (see *Plain strings in edits*). - `to_string` quotes strings that other YAML readers read as numbers (`+-1`, `_1`, `0b-1`, `0X-F`), as its rule to quote every string some reader would read as something else says. A `%` in a tag, and an enum variant with non-ASCII characters written as a tag, are written as escapes. - Setting a scalar keeps its style: a block scalar stays a block scalar of its kind, quotes and a fitting tag stay, and so does the white space after `:`. A plain or quoted scalar set to text with a line break is written double-quoted on its line, where 1.1 wrote a `|` block. - A multi-line string appended after footer comment lines is a literal block, where 1.1 wrote it double-quoted. - Comments that 1.1 dropped or moved stay: the comment lines above a replaced root, and the footer comments of a collection whose only entry is removed. - Diagnostic codes: - `Y1004` now means only a repeated `%TAG` handle. A second `%YAML` directive is the new `Y0033`. - `Y1005` is deprecated and no longer reported. An undeclared tag handle is the new `Y0035`, reported by the parser, so serde reads report it too. - `Y0034` is new: a character that cannot appear in a comment (a control character, U+007F or a C1 control other than U+0085). - Errors: some invalid inputs that 1.1 accepted are now errors (see *Fixed*), and so is a comment holding a control character (`Y0034`); `to_string` refuses (`Y3015`) a `Value::Tagged` whose tag cannot be written so that it reads back (a non-ASCII character, or a stray `%`); many parse errors point at a later character than before; `LineIndex` character and UTF-16 columns no longer count a leading byte order mark. - `oneyaml-syntax` 0.2: the event stream of some inputs changed (document boundaries, error positions and codes). Highlights: `Document`, `DocumentMut` and `Stream` apply JSON Patch (RFC 6902) and merge patches (RFC 7396) all or nothing, keeping comments and layout, and read nodes by JSON Pointer (RFC 6901). Setting a value now writes exactly that value and changes only its text: a block scalar stays a block scalar of the same kind, quotes stay, and so do a tag that still fits, the white space after `:` and the document's line breaks. Many more edits that used to be refused now work. The parser passes long scalars, comments and line ends sixteen bytes at a time, and parse errors point at the character that cannot belong. ### Added - JSON Patch (RFC 6902), merge patch (RFC 7396) and JSON Pointer (RFC 6901) on `Document`, `DocumentMut` and `Stream`: `apply_patch` and `apply_merge_patch`, with the types `Patch`, `PatchOp`, `MergePatch`, `Pointer`, `PatchError` and `PatchErrorKind`, and `Document::get_pointer` and `get_pointer_tokens` to read a node by pointer. - A patch is applied as a whole: if any operation fails, the document is exactly as it was, including its comments and layout. A patch changes only the text of the values it names. - A pointer token matches a mapping key by the key's scalar text (`/80` finds the key `80`, `'80'` and `"80"` alike, whatever tag the key has; an alias key has the text of the node it names), through an index of the mapping's keys built once per patch, so a patch of many operations on a wide mapping costs the operations, not the operations times the width, and the answer does not depend on the mapping's width. Two keys with the same text are refused as ambiguous, and so is a key that a later duplicate of the same meaning overrides (`DuplicateKeys::LastWins`). With `merge_keys` on, keys merged in by `<<` are found, as `get_path` finds them. - The node an alias key names is read once per patch, however many alias keys name it (again only if the patch removes or replaces it), and only a digest and the length of its text are kept, never a copy, so a patch of many operations on a mapping with alias keys does not read them again for each operation. The bytes read are charged to the alias byte budget, as are the bytes of the alias keys that an `add` into the mapping, a `LastWins` duplicate check or a search of merged mappings has to read afresh. Looking at alias keys and merged mappings is also counted in steps, and a patch that spends more than about a million of them (plus twice the document's length in bytes) fails with a `Limit` error. - `Document::get_pointer` keeps the key indexes it builds until the document is next edited, so reading many pointers in a wide mapping indexes it once. - Writes refuse to go through an alias, to change a key that exists only in a mapping merged in by `<<`, and to remove an anchored node that an alias still uses. These are `RefusedAlias` errors. - `test` compares by the YAML data model under the document's schema (Core by default), which departs from RFC 6902 section 4.6: `1` is not `1.0`, and `0x1F` is `31`. A mapping key in a compared value matches a JSON object key only if it is a string under that schema, so the integer key `1` does not match `"1"`. - The bytes a patch copies are charged to the alias byte budget (`Limits::alias_byte_budget`; by default the larger of ten times the document's length and 64 MiB), three times each, because a copied value is held in memory as a value while it is written and takes several times its text. A `copy` charges the length of the text it reads, and a `copy` or `move` that has to write aliases out also charges the size of the written-out value beyond that text, at the same weight. At the default limits a patch can copy about 21 MiB of text in all (a third of 64 MiB; a third of ten times the document when that is larger). A patch over the budget fails with a limit error and changes nothing. The document size limit applies after every write. - `copy` and `move` write the value they read, not its text: an alias inside it becomes its content, and the comments inside a moved value and around its old entry, and its anchors, are not kept. To move an entry with its comments, use `NodeMut::move_entry` or `Removed`. - `Patch::from_json` and `MergePatch::from_json` read JSON or YAML text with the default settings and limits. For untrusted text, read a `Value` with `from_str_with` and your own `Limits`, then use `Patch::from_value` or `MergePatch::new`. - `oneyaml-syntax`: `SyntaxTree::close_block_scalar` adds the missing final line break to a block scalar that ends the text without one, so that lines can go after it (`SyntaxTree::insert_line_after`). - `oneyaml-syntax` has an `__internal-checks` feature, as `oneyaml` does: work counters for this project's own tests. It is not covered by semver and may change or go in any release; never enable it. - `Y0033` (`DUPLICATE_YAML_DIRECTIVE`): a second `%YAML` directive in one document prefix. It is reported where the second directive starts. `Y1004` stays the code for a repeated `%TAG` handle. - `Y0034` (`INVALID_COMMENT_CHARACTER`): a character that cannot appear in a comment (a control character, U+007F, or one of U+0080 to U+009F other than U+0085). The error is at the first such character; the comment is kept as written and the parse goes on. - `Y0035` (`UNDECLARED_TAG_HANDLE`): a tag that uses a handle no `%TAG` directive declares (`!e!x` with no `%TAG !e!`). It is a syntax error, found by the parser, so the event stream, `Document` and the serde reader all report it the same way. It replaces `Y1005` (see *Deprecated*). ### Deprecated - `Y1005` (`UNDEFINED_TAG_HANDLE`): no longer reported; an undeclared tag handle is `Y0035`. The constant and its registry entry stay, marked deprecated, and the number is never reused. ### Changed - Appending a multi-line string to a block collection whose last entry is followed by its footer comment lines (comment lines indented under it, with no `---` after them) writes a literal block (`n: |-`) below those lines. It was written as a double-quoted string. Moving a literal block scalar to the end of such a collection keeps it a literal block too. The comment lines stay where they were, above the new entry. - yaml-test-suite case 4H7K (`[ a, b, c ] ]`): its corrected expectation no longer has `-DOC` before the error, the same rule as BS4K and EB22 (a document ends only at a marker or at the end of the stream). The parser is unchanged; the strict score is now 402/402. - Setting a value over an existing node (`set`, `set_scalar`, `set_path`, `replace_at`, `insert` on an existing key) now writes exactly that value and changes only that value's text: - A literal (`|`) or folded (`>`) block scalar stays a block scalar of the same kind. Its chomping indicator changes only as far as the new text needs: no final line break gives `-`, exactly one gives the clear form (no indicator), more give `+`, and an indicator that already holds the text stays (a `|+` set to one final line break stays `|+`, without the empty line the old text ended with). The content keeps the indentation the old content had. A folded scalar writes one line break between lines of text as an empty line, so it reads back as written. Before, any block scalar was rewritten as a literal block with the default indentation, or as a plain scalar when the new text had no final line break. - A plain, single-quoted or double-quoted scalar set to text with a line break becomes a double-quoted scalar with `\n` escapes on the same line. Before it became a literal block scalar. - A string is written plain or quoted as *Plain strings in edits* below says. - A tag stays on a scalar while the new value reads back under it as exactly that value: a custom tag on a string, `!!str` on a string, `!!int` on an integer, `!!float` on a float, `!!bool` and `!!null`. Any other tag (`!!binary` on a string, `!!int` on a string) is dropped as before. The anchor and tag keep the order the source had. - The white space between `:` (or `-`) and the value stays as the document wrote it (a tab, several spaces), unless the new value is a block collection, which starts after one space. - A block scalar that ends the file without a line break, set to text that needs none, leaves the line break missing (`a: |` with `old` set to `n` gives `a: |-` with `n`). - A text too long to write as a block scalar within `max_document_size` at the scalar's indentation is written another way instead of being built first. - A merge patch applied to a sequence writes the keys it adds under the rule of *Plain strings in edits* below, instead of quoting them for every reader. - Replacing a root node keeps the comment lines above it (`# head` above `a: 1`). Before, they were dropped with the old value. - Removing the only entry of a nested collection (which becomes `{}` or `[]`) leaves the collection's footer comment lines where they are, below the new `{}` or `[]`. Before, they were moved inside the new brackets, and the edit was refused when the collection also had a comment on its properties line. An empty `{}` or `[]` owns no footer, so the comment changes owner: it leads the next entry, or, when no entry follows, it is the enclosing collection's footer. A later removal of that entry takes the comment along. A root collection's footer comments still become the document footer. - A merge patch (or a root replacement by a collection) on an empty document whose start marker line holds only properties and a comment (`--- !!null # c`) keeps the comment on the marker line: `--- # c` followed by the new mapping. Every other root that starts on the marker line (`--- foo # c`, `--- [a] # c`) still refuses to be replaced, because the comment would change owner. - Setting the value of a flow entry written with an empty value (`{b:, c: 1}`) works. Before it was refused. - Adding an entry or item after a block scalar whose text has no final line break (`|-`, or a keep scalar `|+` of one line of spaces) works: the scalar is closed with a line break first, and the new line follows. The input keeps ending without a line break, except when the new value is a multi-line string, which is written as a block scalar that ends with its own line break. Before, these edits were refused. - Any number of byte order marks may precede a document (`\ufeff\ufeffa: 1`, marks on lines of their own, marks after `...`): every document prefix may start with one (YAML 1.2.2 productions 202 and 211), and the events and values are those of the single-mark form. A mark anywhere else, such as in the middle of a line or inside content, is still `Y0032`. Edits accept the same text. - Removing the last entry of a block collection after an entry that ends with a block scalar also removes the lines right under it that would otherwise become part of that scalar's text: a comment indented as far as the scalar's content, or white space wider than it. A line that is not empty and narrower than the content ends the scalar, so it and the lines after it stay. The comments taken are in `Removed::comments`, and `insert_removed_at` writes them back. Before, the removal was refused as not reading back. Removing the only entry of a mapping whose key is a block scalar, when a `:` at the margin follows, now leaves `{}` and keeps that entry. - A path segment of `""` names the entry whose key has no content (`: v`, `[: v]`, `&a : v`), and the entry with a quoted empty key (`'': v`). When both are present the segment is refused as ambiguous (lookups return `None`, edits `EditError::Unsupported`), and `set_path` never adds a second empty key. - Content an edit adds now follows the document around it: - A new string takes the quotes of the strings at the end of the collection it joins (`a: "x"` then `b: "y"` gains `c: "z"`, not `c: z`); the nearest string decides, and a plain one means plain. A new mapping key takes the quotes of the keys beside it. Numbers, booleans and nulls are never quoted, and text that needs escapes or a block is written as it must be. The strings of a new collection are not copied from its neighbours. - Whether a new string is plain is decided as *Plain strings in edits* below says. - Nested block content copies the indentation of a nested collection even when 300 plain entries come before it (the first 1,024 entries are read, and only when the text written has a layout to copy; one line of text reads none, which makes inserting a scalar cheaper). - **Plain strings in edits.** A string an edit writes (a set value, a new entry, key or item, a patch result) is plain when the document's schema reads the plain text back as that string, and quoted when it would read as another type or plain text cannot hold it. Under the default Core schema `yes`, `no`, `on`, `y` and `n` are strings, so inserting `no` writes `restart: no`, where 1.1 wrote `restart: 'no'`; `'123'` and `'true'` stay quoted. 1.1 quoted such words for YAML 1.1 readers, and documents meant for them keep the quotes: - A document read with a YAML 1.1 setting (`yaml11_booleans`, `legacy_octal`, `sexagesimal`, `digit_separators`, `yaml11_int_forms`, or `Compat::yaml_1_1()`): new string values are quoted wherever a YAML 1.1 reader would read another type. New keys and values set over an existing node are quoted when the document's schema reads them as another type (with `yaml11_booleans`, `no` and `y` are booleans). - A document that declares `%YAML 1.1`: new string values and new keys are quoted as in 1.1. A value set over an existing node is quoted when the document's schema reads it as another type: with `honor_version_directive` on, `%YAML 1.1` switches on the YAML 1.1 behaviours, so `no` is quoted; without it, `no` is written plain. - Removing an entry, or replacing a node with a kept (`|+`) block scalar, no longer changes the value of a kept scalar before it: the blank lines that follow the entry go with it (`x: |+` then `two`, an empty line and `y: 1` followed by an empty line used to be refused). Setting or replacing a root with a kept scalar before a document marker writes no extra empty line. - `Document::remove_node` and `remove_keep_comments` now remove an entry whose key is a collection, block or flow, and `Removed::key` gives the key's source text for it (with its anchor and tag, if any). The `Removed` entry can be put back, into the same mapping or another, and is refused as `KeyExists` when the mapping has an equal collection key (the order of a mapping's entries, the spelling of its numbers and the aliases it names do not matter). A *new* entry with a collection key still cannot be inserted. - A flow entry with an explicit key (`{? k : v}`) moved into a block mapping is written as the two lines `? k` and `: v`. It used to be written as `? k : v`, which a block mapping reads as a different key, and so changed the document's meaning. A key or value that spans lines is refused. - Parse errors point at the first character that cannot belong, not at the start of the construct around it. This moves the position of many diagnostics (the code and the message are unchanged unless listed below): a bad escape, an invalid anchor or tag, a flow collection glued to a key or property, a missing `:`, an unterminated quoted scalar, a plain scalar with a second `: `, a bad block scalar header, an implicit key that is too long, a line break inside an implicit key, and so on. A quoted scalar that is still open on a line that is not indented more than its parent is reported at the start of that line (the closing quote of a nested scalar at the margin included), and properties on a line of their own before a key of an open mapping are reported where the key starts. Rendered snippets and `(line:column)` texts move with them. - Columns reported by `LineIndex` for characters and UTF-16 units leave out a byte order mark at the start of the text: the first character after it is column 0 (byte columns still count it). - A document ends only at its end marker, at the next document start, or at the end of the stream. Content on a later line after a plain scalar root is an error inside that document; it no longer starts a second one. The events of such inputs no longer contain an early document end. - A line holding only a tab after a block scalar, at the end of the text or before a document marker (`---` or `...`), is no longer an error. - A second `%YAML` directive in one document prefix is reported as `Y0033` (it was `Y1004`), at the second directive. `Y1004` now covers only a repeated `%TAG` handle. - A percent escape in a tag (`!a%41`, `!`) is decoded when it is the escape of an ASCII character other than `%`. The escape of a non-ASCII byte (`%C3`) and `%25` are kept as written, so that tags that differ only in such an escape stay different tags (`!a%25C3%25A9` and `!a%C3%A9` are two tags, and as mapping keys not duplicates). A tag read from a document is written back as it was read. The writer refuses (`Y3015`) a `Value::Tagged` whose tag has a non-ASCII character, or a `%` that no reader produces (a bare `%`, or the escape of an ASCII character other than `%`), because no spelling reads back as that tag. - An enum variant named with non-ASCII characters (`#[serde(rename = "café")]`) is written, in the layouts that write a variant as a tag, as the escaped name (`!caf%C3%A9 1`), and a `%` in a variant name as `%25`; a tag is matched to a variant by its name with every `%XX` escape decoded, so output of earlier releases still reads. A tag that names no variant is handed to the target decoded as well. - An escaped surrogate pair in a double-quoted scalar (`"\uD83D\uDE00"`) reads as one character. - A float that is outside the range of a double (`1e400`, `1e-400`) keeps its exact value as a key, so `1e400` and `2e400` are different keys instead of both reading as infinity; they still read as the double they round to. - Strings that other readers would read as numbers are quoted when written: forms with two signs (`+-1`), with a leading `_` (`_1`), and with a sign after a radix prefix in either case (`0b-1`, `0X-F`). Before, `to_string` wrote them plain. ### Fixed - Inputs the parser accepted though they are invalid are errors now: a flow collection directly after a property, an empty key, an explicit key or a plain key (`&a[b]`, `{:{0}}`), a verbatim tag that is not a URI, and a second anchor on one node. ### Performance - `oneyaml-syntax`: the parser passes long runs of plain, quoted and block scalar text, comments and line ends sixteen bytes at a time instead of one character at a time, and reads the value of a quoted or plain scalar from such runs. Every event, span, value and diagnostic is the same as before (checked over the whole yaml-test-suite, the fuzz corpora and CRLF, CR, byte-order-mark and trailing-space variants of them, under four settings). In the benchmark tables of [`docs/performance.md`](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md) (CPU instructions counted by valgrind, not wall-clock time; 1.2.0 against the 1.1.0 tables), every parse row needs fewer instructions: block scalars 54% fewer, long scalars 52%, Helm values 41%, comment runs 39%, deeply nested block mappings 30%, the Kubernetes, OpenAPI, compose, GitHub Actions, wide-map and whitespace rows 17% to 25%, anchors 13%, flow-heavy input 8% and deeply nested flow sequences 5%. Serde reads need 5% to 25% fewer, `Document` parses 7% to 20% fewer and edit scripts 1% to 26% fewer; serde writes need up to 1% more. Allocation counts are unchanged; a parsed `Document` of each of the three inputs below 100 KB retains up to 2% more bytes. ## 1.1.0 — 2026-10-10 `oneyaml` 1.1.0, `oneyaml-core` 1.1.0, `oneyaml-syntax` 0.1.2. No public item is added, removed or changed (cargo-semver-checks finds no change); MSRV unchanged (Rust 1.85). The reworded parse error messages come from `oneyaml-syntax` 0.1.2 and the `Debug` form of `Code` from `oneyaml-core` 1.1.0, so a project on `oneyaml` 1.0.1 also sees them after `cargo update`. ### Changed - Reading a YAML 1.1 boolean word (`y`, `yes`, `on`, `n`, `no`, `off` and their capitalised forms) into a `bool` explains the YAML 1.2 reading: the message says the word is a string in YAML 1.2, the help says to write `true` or `false` or to turn on `Settings::yaml11_booleans` (or use `Compat::yaml_1_1()`), and a suggested fix writes the 1.2 boolean. The code is unchanged (`Y3000`), and so is what is accepted. The explanation is given when reading YAML text (`from_str`, `from_slice`, `from_reader`, `Document` and node reads) for a plain, untagged word under the default Core schema; `from_value` keeps the plain `invalid type` message, as a `Value` records no source style. The [migration guide](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/migrating-from-serde-yaml.md#yaml-11-behaviours) shows the new message for `debug: yes` read into a `bool`. - Clearer plain messages for parse errors whose old one-line form hid the likely cause; codes and spans are unchanged: - a line inside a flow collection that is not indented more than the collection's block parent: when the line is the closing bracket, or closes the collection itself (common in JSON-style YAML), the message says that only its indentation is short and the suggested fix indents it (it used to suggest a second closing bracket); when the collection is still open at that line, the message says a `]` or `}` may be missing before it; - a quoted scalar still open at a less-indented line says a closing quote may be missing; - text glued to a closing quote names the cause: a quote inside the scalar (`'it's'`) only when the text runs to the same quote again, a `#` without the space a comment needs (`"a"#c`), or other text, whose help no longer suggests escaping a quote; - a plain value that runs onto the next line and is then followed by `:` says how it became a key, with help. - `Debug` for `Code` prints the `Y####` form (`Y0005`), as `Display` does, so codes read the same inside a derived `Debug` such as a `Diagnostic`'s. - Documentation: both READMEs open with a compact benchmark chart, a short *Why oneyaml* and the quick start. The chart counts CPU instructions, not wall-clock time: one bar per workload for serde reads, serde writes, `Document` parsing and an edit script, with each group's range and the number of workloads where oneyaml is slower; its alt text carries every number it shows. Under it is one link to [Results at a glance](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md#results-at-a-glance) in `docs/performance.md`, which now holds the full chart and the tables: both libraries' instruction counts, the instruction ratio, a wall-clock “× faster” column from the measured medians, a note naming every row where oneyaml is slower in wall-clock time, and the machine and versions measured. The charts and tables are generated from the same run as the rest of that page, re-measured on the current code (so they include the faster typed reads with merge keys described below). In both charts, bars longer than 3.5× are drawn broken, so one large ratio no longer squashes the others (the full chart prints each such bar's value; the compact chart's group range gives the largest), and slower bars are hatched as well as coloured. - Documentation: the crate README has a new *Diagnostics* section that shows a rendered error with its suggested fix, and the repository's front page now has the quick start. - Typed reads with `Settings::merge_keys` on (and duplicate keys refused, the default) no longer buffer each mapping before handing it to the target: entries stream as they are read, and only the value of each `<<` is held until the mapping ends. Typed reads of files with merge keys are faster and use less memory. The same inputs are accepted and rejected, with the same values. What changes is which error a typed read reports when an input has more than one problem: it is now the first one met while reading, as with merge keys off. For example, `a: x` followed later by a duplicate key, read into a struct with `a: u32`, now reports the type error at `a` rather than the duplicate key. Error paths use the key as written (`~`, `0x10`), as with merge keys off. An error in content the target skips (an unknown field, the rest of a mapping after the target stops) carries the path of the entry being skipped rather than the full path into it, as with merge keys off. A target that ignores an error in the input now gets `Y3018` on its next read, as with merge keys off. An error inside a `<<` value is reported when that value is read, at a path through `<<` (for example `services.web.<<.ports[0]`). `Value` reads, `Document`, and reads with `DuplicateKeys::LastWins` are unchanged. ## 1.0.1 — 2026-10-06 `oneyaml` 1.0.1, `oneyaml-core` 1.0.1, `oneyaml-syntax` 0.1.1. Documentation and packaging only: no API, behaviour or output change. ### Changed - Documentation rewritten for readers of the published crates: the API reference, the READMEs, the performance, conformance, comment-attachment and migration documents, and the contributor guide no longer refer to non-published files or development history. - The benchmark tables in `docs/performance.md`, the README summary and its chart were re-measured on the 1.0.1 release tree. One serde row, typed reads of a docker-compose file with merge keys, is now just over oneyaml's own 1.25× target; the library code is the same as in 1.0.0. - The changelog starts at 1.0.0; earlier versions were never published. ## 1.0.0 — 2026-10-05 Initial release: `oneyaml` 1.0.0, `oneyaml-core` 1.0.0 and `oneyaml-syntax` 0.1.0. MSRV: Rust 1.85. oneyaml reads and writes YAML 1.2 through serde, and edits YAML files losslessly, keeping comments, with one parser behind both, so the two never disagree about what a file means. ### Added - **Serde** reads and writes: `from_str`, `from_slice`, `from_reader`, `to_string`, `to_writer`, `from_value`, `to_value`, with `serde_yaml`-compatible names; multi-document streams through `Deserializer`; borrowing deserialization; `Spanned`; tagged enums. - **`Value`**, a dynamic value type that keeps tags and allows any value as a mapping key, with non-panicking indexing. - **Lossless editing** with `Document` and `Stream`: parsing and writing back an untouched file gives the same bytes; typed reads and edits through `NodeRef`/`NodeMut` handles and string paths (`"spec.containers[0].image"`); every edit keeps the rest of the file, comments included, byte for byte, and lays out new content like its neighbours. - **Comments**: every comment has an owner set by a numbered [rulebook](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/comment-attachment.md); edits move comments with their owner, and `explain_comment` says which rule decided. - **Anchors and aliases** in edits: editing an anchored node is seen through every alias, and removing a node an alias still uses is refused. - **YAML 1.2.2 by default**, with the Core schema (Failsafe and JSON built in). Every YAML 1.1 behaviour is an opt-in, named setting: **merge keys (`<<`)** with `Settings::merge_keys`, 1.1 booleans, legacy octal, sexagesimal numbers and more; `Compat::yaml_1_1()` and `Compat::serde_yaml()` presets. - **Diagnostics**: every error has a stable `Y####` code, a span and a path; common mistakes come with a suggested fix; `Render` prints a rustc-style snippet; `Document::parse_recovering` never fails. - **Resource limits** on every entry point (depth, alias expansion in nodes and bytes, document size, scalar length, document count, diagnostics), with `Limits::default()`, `Limits::untrusted()` for input from the network, and `Limits::unlimited()`. - **No `unsafe`, no panics**: every published crate is `#![forbid(unsafe_code)]`; every input gives a value or an error, and deep nesting can't overflow the stack. - **Feature flags**, off by default: `miette` (diagnostics as `miette::Diagnostic`) and `serde_json` (conversions between `Value` and `serde_json::Value`). - **`oneyaml-syntax`**: the lossless decoder, event stream and syntax tree underneath, for tooling. Evidence for these claims: [yaml-test-suite conformance](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/conformance.md), [performance](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/docs/performance.md) and [fuzzing](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/fuzz/README.md). # Parse YAML into Rust structs Read a YAML file into typed Rust data with serde: nested structs, a map, an optional section, a default and an enum. oneyaml is a serde data format like `serde_json`, so the same `#[serde(...)]` attributes apply. ```sh cargo add oneyaml@1.2.0 serde -F serde/derive ``` The file: ```yaml # service.yaml name: web image: nginx:1.27 env: LOG_LEVEL: info PORT: "8080" healthcheck: path: /healthz intervalSeconds: 10 restart: on-failure ``` The program: ```rust use serde::Deserialize; use std::collections::BTreeMap; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct Service { name: String, image: String, #[serde(default = "one")] replica_count: u32, env: BTreeMap, healthcheck: Option, restart: Restart, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct Healthcheck { path: String, interval_seconds: u32, } #[derive(Debug, Deserialize)] #[serde(rename_all = "kebab-case")] enum Restart { Always, OnFailure, Never, } fn one() -> u32 { 1 } fn main() -> Result<(), Box> { let src = std::fs::read_to_string("service.yaml")?; let svc: Service = oneyaml::from_str(&src)?; println!("{} runs {} x{}", svc.name, svc.image, svc.replica_count); println!("env: {:?}", svc.env); if let Some(h) = &svc.healthcheck { println!("health check: {} every {}s", h.path, h.interval_seconds); } match svc.restart { Restart::Always => println!("restart: always"), Restart::OnFailure => println!("restart: on failure"), Restart::Never => println!("restart: never"), } Ok(()) } ``` ```text web runs nginx:1.27 x1 env: {"LOG_LEVEL": "info", "PORT": "8080"} health check: /healthz every 10s restart: on failure ``` ## Good to know - `replicaCount` isn’t in the file, so `#[serde(default = "one")]` fills it. Without a default, a missing field is an error that names it. - `PORT: "8080"` is quoted, so it reads as a string into the `BTreeMap`. Unquoted, `8080` is an integer, and reading it into a `String` is a type error. oneyaml doesn’t convert scalars behind your back. - The YAML 1.2 Core schema is the default: plain `yes`, `no`, `on` and `off` are strings, and `0755` is 755. To read a file written for YAML 1.1, see [YAML 1.1 booleans](https://recursiveai.org/oneyaml/rust/cookbook/yaml-1-1-booleans/). - `from_slice` and `from_reader` read bytes and any `io::Read`. Each has a `_with` form that takes `Settings`. When the shape isn’t known ahead of time, read a `Value` instead: [read YAML without a schema](https://recursiveai.org/oneyaml/rust/cookbook/read-yaml-without-a-schema/). A field of the wrong type is reported with its line, column and path: [errors with line and column](https://recursiveai.org/oneyaml/rust/cookbook/errors-with-line-and-column/). # Read YAML without a schema When you don’t have a struct for the data, read it into `oneyaml::Value` and index it like `serde_json::Value`. ```rust use oneyaml::Value; fn main() -> Result<(), oneyaml::Error> { let src = "\ server: host: 0.0.0.0 port: 8080 features: [auth, metrics] limits: cpu: 0.5 memory: 512Mi "; let v: Value = oneyaml::from_str(src)?; // Index by key or position; a missing key gives Value::Null, not a panic. println!("host = {:?}", v["server"]["host"].as_str()); println!("port = {:?}", v["server"]["port"].as_u64()); println!("feature0 = {:?}", v["features"][0].as_str()); println!("missing = {:?}", v["server"]["tls"].as_str()); // Walk a mapping in file order. if let Some(limits) = v["limits"].as_mapping() { for (k, val) in limits { println!("limit {} = {:?}", k.as_str().unwrap_or("?"), val); } } Ok(()) } ``` ```text host = Some("0.0.0.0") port = Some(8080) feature0 = Some("auth") missing = None limit cpu = Number(0.5) limit memory = String("512Mi") ``` ## Good to know - Indexing with `v["key"]` or `v[0]` never panics: a missing key or index gives `Value::Null`, so `as_str()` and friends return `None`. - A mapping keeps the order of the file. - `0.5` is a number and `512Mi` a string: the Core schema decides, the same way for `Value` as for a struct. - To read values and also edit the file, use a `Document` instead: `doc.get_path("server.port")` reads a node with its span, comments and style ([edit YAML and keep its comments](https://recursiveai.org/oneyaml/rust/cookbook/edit-yaml-keep-comments/)). - `oneyaml::from_value` turns a `Value` into a typed struct later, once you know which one it is. # Edit YAML and keep its comments Change a YAML file from Rust the way a person would: one value, one list item, one new key, and every comment, blank line and quote elsewhere stays as it was. Parse it into a `Document`, edit, and write it back. ```rust use oneyaml::Document; fn main() -> Result<(), Box> { let src = "\ # App settings, edited by hand and by tools. server: host: 0.0.0.0 port: 8080 # behind the load balancer # Feature flags features: - auth - legacy-export # remove after the 2.0 release - metrics "; let mut doc = Document::parse(src)?; // Change one value: its trailing comment stays. doc.set_path("server.port", 9090)?; // Remove a list item: its comment goes with it. let removed = doc.get_mut("features")?.remove_at(1)?; println!("removed {:?} with {:?}", removed.text(), removed.comments()); // Add a key with a comment of its own. let mut server = doc.get_mut("server")?; let mut timeout = server.insert("timeout", "30s")?; timeout.set_trailing(Some("per request"))?; print!("{doc}"); Ok(()) } ``` ```text removed "legacy-export # remove after the 2.0 release" with ["# remove after the 2.0 release"] # App settings, edited by hand and by tools. server: host: 0.0.0.0 port: 9090 # behind the load balancer timeout: 30s # per request # Feature flags features: - auth - metrics ``` ## Good to know - **Comments stay with their entry.** The `# behind the load balancer` comment stays on `port`, and the comment of the removed item goes with it. Which entry owns a comment is decided by [numbered rules](https://recursiveai.org/oneyaml/rust/docs/comment-attachment/); `doc.explain_comment(id)` says which rule applied. - **A removed entry isn’t lost.** `remove_at` returns a `Removed` with the entry’s text and comments, which you can put back elsewhere with `insert_removed`. `remove_keep_comments` removes an entry but leaves its comments in place. - **Values keep their style.** `set_path` and `NodeMut::set` keep the old value’s quotes, block style or tag when they still fit the new value. - **Comments of your own.** `set_trailing` writes the comment after a value, `set_leading` the lines above it, and `Document::set_header` the comment at the top of the file. `set_blank_lines_before` adds spacing. - An untouched document writes back byte for byte: `Document::parse(src)?.to_string() == src`. To change many values at once from a JSON document, use a patch: [merge or patch YAML](https://recursiveai.org/oneyaml/rust/cookbook/patch-yaml/). The same edit in Go, Rust and C#: [Edit YAML without losing comments](https://recursiveai.org/oneyaml/articles/edit-yaml-keep-comments/). # Merge or patch YAML Overlay one YAML or JSON document on another, or apply a JSON Patch, without losing the file’s comments and layout. A merge patch suits environment overrides; a JSON Patch suits scripted changes with a `test` guard. ```rust use oneyaml::{Document, MergePatch, Patch, PatchErrorKind}; fn main() -> Result<(), Box> { let src = "\ # values.yaml image: repository: registry.example.com/web tag: \"1.4.2\" # set by CI replicas: 2 resources: limits: cpu: 500m memory: 256Mi "; // An environment overlay as a merge patch: objects merge, null deletes. let mut doc = Document::parse(src)?; let overlay = MergePatch::from_json(r#"{"replicas": 5, "resources": {"limits": {"cpu": null, "memory": "1Gi"}}}"#)?; doc.apply_merge_patch(&overlay)?; print!("{doc}"); // A JSON Patch with a guard: `test` must pass or nothing changes. let bump = Patch::from_json( r#"[{"op": "test", "path": "/image/tag", "value": "1.4.2"}, {"op": "replace", "path": "/image/tag", "value": "1.5.0"}]"#, )?; doc.apply_patch(&bump)?; println!("tag now {:?}", doc.get_path("image.tag").and_then(|n| n.as_str())); // Applied again, the test fails, and the document is left as it was. let err = doc.apply_patch(&bump).err().ok_or("expected the test to fail")?; println!("second run: {:?} at op {:?}", err.kind() == PatchErrorKind::Test, err.op_index()); Ok(()) } ``` ```text # values.yaml image: repository: registry.example.com/web tag: "1.4.2" # set by CI replicas: 5 resources: limits: memory: 1Gi tag now Some("1.5.0") second run: true at op Some(0) ``` ## Good to know - **All or nothing.** If any operation fails, the document is exactly as it was. `err.kind()` says why, and `err.op_index()` which operation. - **A merge patch merges mappings and replaces everything else.** `null` deletes a key; a list in the patch replaces the whole list. - **Only the named values change.** The `# set by CI` comment and the quotes around the tag stay. - **YAML rules, not JSON rules.** `test` compares YAML values (`1` is not `1.0`), a pointer token matches a key by its text, and writes below an alias or to a key merged in by `<<` are refused. A `copy` or `move` writes the value without its comments. - `Patch::from_json` and `MergePatch::from_json` also read YAML text. For text from outside, read a `Value` with `Limits::untrusted()` first and build the patch with `Patch::from_value` or `MergePatch::new`. Every difference from RFC 6902 is listed under [patches and pointers](https://recursiveai.org/oneyaml/rust/docs/guide/#patches-and-pointers). A longer guide: [Apply a JSON Patch to a YAML file](https://recursiveai.org/oneyaml/articles/json-patch-yaml/). # Convert YAML to JSON and back Turn a YAML file into JSON, or JSON into YAML. oneyaml’s `serde_json` feature converts between `oneyaml::Value` and `serde_json::Value`. ```sh cargo add oneyaml@1.2.0 -F serde_json cargo add serde_json ``` ```rust use oneyaml::Value; fn main() -> Result<(), Box> { // YAML to JSON: read a Value, convert it (feature "serde_json"). let yaml = "\ name: web ports: [80, 443] tls: true owner: ~ "; let v: Value = oneyaml::from_str(yaml)?; let json = serde_json::Value::try_from(&v)?; println!("{}", serde_json::to_string_pretty(&json)?); // JSON to YAML: JSON is valid YAML 1.2, so read it and write YAML. let text = r#"{"name": "web", "ports": [80, 443], "env": {"LOG": "info"}}"#; let v: Value = oneyaml::from_str(text)?; print!("{}", oneyaml::to_string(&v)?); // Values with no JSON form are an error with a path, not a silent change. let v: Value = oneyaml::from_str("ratio: .nan\n")?; println!("{}", serde_json::Value::try_from(&v).unwrap_err()); Ok(()) } ``` ```text { "name": "web", "owner": null, "ports": [ 80, 443 ], "tls": true } name: web ports: - 80 - 443 env: LOG: info float .nan has no JSON form (JSON numbers are finite) at ratio ``` ## Good to know - **No silent coercion.** What JSON can’t hold is an error with its path: tags, non-string keys, `.nan` and `.inf`, integers beyond 64 bits, and nesting past 128 levels. `Value::to_json_with` lists them all. - The JSON keys above come out sorted because `serde_json::Value` sorts them by default. Turn on `serde_json`’s `preserve_order` feature to keep the file’s order. - JSON is valid YAML 1.2, so `oneyaml::from_str` reads JSON text directly. The YAML written uses block style. - For typed data, skip `Value`: read into your struct with `oneyaml::from_str` and write it with `serde_json::to_string`. # Read Kubernetes manifests and multi-document YAML A Kubernetes manifest often holds several documents separated by `---`. Read each one into a struct, or edit one of them and write the whole file back with its comments. The file: ```yaml # Web frontend apiVersion: apps/v1 kind: Deployment metadata: name: web spec: template: spec: containers: - name: web image: registry.example.com/web:1.4.2 # pinned by release tooling --- apiVersion: v1 kind: Service metadata: name: web spec: ports: - port: 80 targetPort: 8080 ``` The program: ```rust use oneyaml::{Deserializer, Stream}; use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct Object { api_version: String, kind: String, metadata: Metadata, } #[derive(Debug, Deserialize)] struct Metadata { name: String, } fn main() -> Result<(), Box> { let src = std::fs::read_to_string("app.yaml")?; // Typed: one Deserializer per document. for doc in Deserializer::from_str(&src) { let obj = Object::deserialize(doc)?; println!("{} {} ({})", obj.kind, obj.metadata.name, obj.api_version); } // Lossless: edit the Deployment, keep everything else byte for byte. let mut stream = Stream::parse(src)?; let i = stream .documents() .position(|d| d.get_path("kind").and_then(|n| n.as_str()).as_deref() == Some("Deployment")) .ok_or("no Deployment")?; let mut deploy = stream.document_mut(i).ok_or("no document")?; deploy.set_path("spec.template.spec.containers[0].image", "registry.example.com/web:1.5.0")?; print!("{stream}"); Ok(()) } ``` ```text Deployment web (apps/v1) Service web (v1) # Web frontend apiVersion: apps/v1 kind: Deployment metadata: name: web spec: template: spec: containers: - name: web image: registry.example.com/web:1.5.0 # pinned by release tooling --- apiVersion: v1 kind: Service metadata: name: web spec: ports: - port: 80 targetPort: 8080 ``` ## Good to know - Iterating a `Deserializer` yields one deserializer per document, in order. A document you don’t deserialize before asking for the next is skipped. - `oneyaml::from_str` reads a single document. Given a stream with more than one, it’s an error, not a silent read of the first. - `Stream::parse` keeps every document with its comments. `stream.documents()` gives a read view of each, and `document_mut(i)` edits one in place. Each document writes its own region of the file, so the Service’s text is untouched. - The default limits include a cap on documents per stream. For manifests from outside, use `Limits::untrusted()` ([read untrusted YAML safely](https://recursiveai.org/oneyaml/rust/cookbook/untrusted-input/)). The crate repository has a fuller example that edits a Deployment and a Service and checks every comment’s owner: [`k8s_manifest.rs`](https://github.com/recursive-ai-oss/oneyaml-rust/blob/v1.2.0/crates/oneyaml/examples/k8s_manifest.rs). # Report errors with line and column Validate a YAML file and say exactly what’s wrong and where. Every oneyaml error has a stable `Y####` code, a position and, for typed reads, the path to the value. ```rust use oneyaml::{ColumnUnit, Document, LineIndex}; use serde::Deserialize; #[derive(Debug, Deserialize)] struct Config { #[allow(dead_code)] port: u16, } fn main() { // A typed read: the error has a code, a position and a path. let src = "port: 99999\n"; let err = oneyaml::from_str::(src).unwrap_err(); let at = err.location().expect("a position"); println!("{} at line {}, column {}", err.code(), at.line(), at.column()); println!("path: {}", err.path().map(|p| p.to_string()).unwrap_or_default()); println!("one line: {err}"); // Or the rustc-style snippet, for a terminal. print!("{}", err.render(src)); // Every error in a file, not only the first. let src = "name: web\nname: api\nports: [80, 443\n"; let parsed = Document::parse_recovering(src); let lines = LineIndex::new(src); for d in parsed.diagnostics() { let at = lines.line_col(d.primary.span.start, ColumnUnit::Char).expect("in bounds"); println!("{}:{}: {} {}", at.line + 1, at.column + 1, d.code, d.message); } } ``` ```text Y3001 at line 1, column 7 path: port one line: integer 99999 is out of range for u16 at port (1:7) error[Y3001]: integer 99999 is out of range for u16 --> 1:7 | 1 | port: 99999 | ^^^^^ | = path: port 2:1: Y1013 duplicate key `name` 4:1: Y0022 flow collection is not closed ``` ## Good to know - `err.location()` gives a 1-based line and column (in characters) and the byte offset. `err.span()` gives the byte range. - **Codes are stable.** Match on `err.code()` (or `err.kind()` for the broad category), not on the message. Codes are never reused. - **Fixes come with many errors.** `err.diagnostic().suggestions` holds suggested fixes you can apply with `fix.apply(src)`. A fix is marked machine-applicable only when it can’t change what the rest of the file means. - `Document::parse_recovering` keeps going past the first error and returns the document with every diagnostic. `render_diagnostics` prints them all as snippets. - With the `miette` feature, `Error` implements `miette::Diagnostic`, for miette’s reports. More in [diagnostics](https://recursiveai.org/oneyaml/rust/docs/guide/#diagnostics). # Anchors, aliases and merge keys Anchors (`&name`) and aliases (`*name`) reuse a node. Merge keys (`<<`) copy a mapping’s entries into another, as docker-compose and many CI files do. YAML 1.2 has no merge keys, so oneyaml reads `<<` as a plain key unless you turn them on. ```rust use oneyaml::{Document, Settings, Value}; fn main() -> Result<(), Box> { let src = "\ defaults: &defaults image: nginx:1.27 replicas: 2 web: <<: *defaults replicas: 3 worker: *defaults "; // Aliases are expanded on read; `<<` is a plain key in YAML 1.2. let v: Value = oneyaml::from_str(src)?; println!("worker.image = {:?}", v["worker"]["image"].as_str()); println!("web.image without merge keys = {:?}", v["web"]["image"].as_str()); // Turn on YAML 1.1 merge keys for docker-compose-style files. let v: Value = oneyaml::from_str_with(src, &Settings::new().merge_keys(true))?; println!("web.image = {:?}, web.replicas = {:?}", v["web"]["image"].as_str(), v["web"]["replicas"].as_u64()); // A Document keeps anchors and aliases as written, and can follow them. let doc = Document::parse(src)?; let worker = doc.get("worker").ok_or("no worker")?; println!("worker is an alias of {:?}", worker.alias_name()); let target = worker.alias_target().ok_or("no target")?; println!("target anchor {:?}, image {:?}", target.anchor(), target.get("image").and_then(|n| n.as_str())); Ok(()) } ``` ```text worker.image = Some("nginx:1.27") web.image without merge keys = None web.image = Some("nginx:1.27"), web.replicas = Some(3) worker is an alias of Some("defaults") target anchor Some("defaults"), image Some("nginx:1.27") ``` ## Good to know - `Settings::new().merge_keys(true)` turns on merge keys alone. `Compat::yaml_1_1()` turns on every YAML 1.1 behavior, merge keys included. - Keys in the mapping win over merged ones: `web.replicas` is 3, not 2. - **Alias expansion is budgeted.** Expanding aliases, merges included, counts toward the alias budget of `Limits`, so a “billion laughs” file fails fast ([read untrusted YAML safely](https://recursiveai.org/oneyaml/rust/cookbook/untrusted-input/)). - **A `Document` never expands aliases.** It keeps `&` and `*` as written, and `alias_target()` follows one. An edit below an alias is refused, because it would change every place that shares the node. - `NodeMut::push_alias`, `insert_alias` and `insert_merge` write new aliases and merge keys. Merge keys across Go, Rust and C# libraries, measured: [YAML anchors, aliases and merge keys explained](https://recursiveai.org/oneyaml/articles/yaml-anchors-aliases-merge-keys/). # Read untrusted YAML safely YAML from a request body, an upload or another tenant can be built to exhaust memory or the stack. oneyaml enforces resource limits on every read; for input you don’t control, use the stricter `Limits::untrusted()` profile. ```rust use oneyaml::{Limits, Settings, Value, code}; fn main() { let settings = Settings::new().limits(Limits::untrusted()); // A normal request body reads as usual. let ok: Result = oneyaml::from_str_with("name: web\nports: [80]\n", &settings); println!("ok: {}", ok.is_ok()); // "Billion laughs": nine levels of aliases, each repeated ten times. let mut bomb = String::from("l0: &l0 [x, x, x, x, x, x, x, x, x, x]\n"); for i in 1..9 { let refs = vec![format!("*l{}", i - 1); 10].join(", "); bomb.push_str(&format!("l{i}: &l{i} [{refs}]\n")); } let err = oneyaml::from_str_with::(&bomb, &settings).unwrap_err(); println!("refused: {} ({})", err.code() == code::ALIAS_BUDGET_EXCEEDED, err.code()); // Deep nesting is refused too, without overflowing the stack. let deep = "[".repeat(100_000); let err = oneyaml::from_str_with::(&deep, &settings).unwrap_err(); println!("deep: {}", err.code()); } ``` ```text ok: true refused: true (Y2004) deep: Y2000 ``` ## Good to know - There are three profiles: `Limits::default()` (for configuration files), `Limits::untrusted()` and `Limits::unlimited()`, the only one without alias budgets. - They bound nesting depth, alias expansion, document size, scalar length and document count. An alias whose whole expansion would exceed the budget is refused before it’s replayed, so the bomb above fails fast. - Parsing is iterative, so deep nesting can’t overflow the stack, and no input makes oneyaml panic. The crate is fuzzed. - The error’s help names the setting to raise. The values of each profile are on [`Limits`](https://docs.rs/oneyaml/1.2.0/oneyaml/struct.Limits.html). - Compatibility presets such as `Compat::yaml_1_1()` never change limits. More in [untrusted input](https://recursiveai.org/oneyaml/rust/docs/guide/#untrusted-input). # Read YAML 1.1 files: `yes`, `no` and `0755` In YAML 1.1, `no` is `false` (the “Norway problem”) and `0755` is octal. YAML 1.2 fixed both: `no` is a string and `0755` is the decimal 755. oneyaml reads YAML 1.2 by default; when a file was written for an older parser, turn on the YAML 1.1 behaviors you need. ```rust use oneyaml::{Compat, Settings, Value}; fn main() -> Result<(), oneyaml::Error> { let src = "\ country: no enabled: yes mode: 0755 "; // YAML 1.2 (the default): `no` and `yes` are strings, 0755 is 755. let v: Value = oneyaml::from_str(src)?; println!("1.2: {:?} {:?} {:?}", v["country"], v["enabled"], v["mode"]); // One YAML 1.1 behavior at a time... let v: Value = oneyaml::from_str_with(src, &Settings::new().yaml11_booleans(true))?; println!("1.1 booleans: {:?} {:?}", v["country"], v["enabled"]); // ...or all of them, as older YAML 1.1 parsers read the file. let v: Value = oneyaml::from_str_with(src, &Compat::yaml_1_1())?; println!("1.1: {:?} {:?} {:?}", v["country"], v["enabled"], v["mode"]); Ok(()) } ``` ```text 1.2: String("no") String("yes") Number(755) 1.1 booleans: Bool(false) Bool(true) 1.1: Bool(false) Bool(true) Number(493) ``` ## Good to know - Each YAML 1.1 behavior is its own setting, off by default: `yaml11_booleans`, `legacy_octal`, `sexagesimal`, `digit_separators`, `yaml11_int_forms` and `merge_keys`. - `Compat::yaml_1_1()` turns them all on. `Compat::serde_yaml()` gets close to `serde_yaml`’s behavior, for a migration; the [migration guide](https://recursiveai.org/oneyaml/rust/docs/migrating-from-serde-yaml/) lists every difference. - A boolean field given `yes` under YAML 1.2 is an error whose help suggests writing `true`, with a fix you can apply ([errors with line and column](https://recursiveai.org/oneyaml/rust/cookbook/errors-with-line-and-column/)). - Quoted values are always strings: `"no"` is the string `no` under every setting. Moving off serde_yaml: [serde_yaml is deprecated: what now](https://recursiveai.org/oneyaml/articles/serde-yaml-deprecated/). Why NO becomes false, and what nine YAML libraries do with it: [The YAML Norway problem](https://recursiveai.org/oneyaml/articles/yaml-norway-problem/).